Help us shape the future of Go! Take the 2026 Go Developer Survey

webhookdelivery

package
v1.8.7 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 3, 2026 License: Apache-2.0 Imports: 14 Imported by: 0

Documentation

Overview

Package webhookdelivery is Layer 3 of the webhook pipeline (docs/design/webhook-delivery-river-migration.md): the River execution stage that POSTs a webhook_subscriber_deliveries (Layer 2) row to the customer endpoint and retries it. It replaces the hand-rolled SKIP-LOCKED claim + retry worker (internal/webhook/subscriber_retry.go) with a River Worker on the shared `webhook` queue; River owns claim/lease/retry/backoff, and Layer 2 becomes pure delivery state written here for the history API.

Delivery is at-least-once (the industry standard — Stripe/Svix/Postmark): River re-drives a crashed job, so an endpoint may receive a duplicate if the POST succeeds but the worker crashes before writing `delivered`. Consumers dedup on the event id in the signed payload.

Index

Constants

View Source
const MaxDeliveryAttempts = 8

MaxDeliveryAttempts is River's MaxAttempts for a delivery job — after this many failed attempts River discards and the worker marks Layer 2 'failed'.

View Source
const MaxDisabledSnoozes = 24

MaxDisabledSnoozes caps how many times a delivery may snooze behind a disabled webhook before it is written to a truthful terminal 'failed' ("webhook disabled"). 24 snoozes × the 1h disabledSnooze ≈ 24 hours of grace — a user disabling an endpoint for a maintenance window loses nothing, while a delivery can no longer sit customer-visibly 'pending' (and wake hourly) until its 90-day expiry.

TUNABLE: 24 is the design's proposed value, not yet frozen — if "no one disables an endpoint for a whole day", 6-8 is defensible and tightens SC3 (docs/design/2026-08-08-webhook-health-notifications.md, Q2).

Variables

This section is empty.

Functions

This section is empty.

Types

type DeliverWorker

type DeliverWorker struct {
	river.WorkerDefaults[WebhookDeliverArgs]
	// contains filtered or unexported fields
}

DeliverWorker delivers one Layer 2 row. Mirrors the legacy processOne: re-fetch the webhook per attempt (disabled → snooze, deleted → cancel), POST, write Layer 2 state. River owns retry/backoff via NextRetry below.

func NewDeliverWorker

func NewDeliverWorker(subStore *webhook.SubscriberStore, deliverer Deliverer, webhooks WebhookReader) *DeliverWorker

NewDeliverWorker constructs the worker. Used by the Registrar and by tests.

func (*DeliverWorker) NextRetry

func (w *DeliverWorker) NextRetry(job *river.Job[WebhookDeliverArgs]) time.Time

NextRetry overrides River's client-wide policy for webhook jobs only, returning the exact 29h21m envelope. job.Attempt is the just-failed attempt (1-based); River discards at MaxAttempts so this is called for attempts 1..MaxDeliveryAttempts-1.

func (*DeliverWorker) WithMetrics added in v1.2.2

func (w *DeliverWorker) WithMetrics(m Metrics) *DeliverWorker

WithMetrics swaps in a metrics backend. Nil-safe: unset (or nil) means no emission, so tests don't have to wire anything.

func (*DeliverWorker) Work

Work intentionally has no Timeout() override — a single HTTP POST (bounded by the deliverer's own client timeout) fits River's 60s default JobTimeout.

type Deliverer

type Deliverer interface {
	Deliver(ctx context.Context, url string, body []byte, secret, secretPrev, eventType, schemaVersion string) webhook.DeliveryOutcome
}

Deliverer is the POST surface. *webhook.SubscriberDeliverer satisfies it.

type Jobs

type Jobs struct {
	// contains filtered or unexported fields
}

Jobs is the webhook-delivery integration on the shared River client: a jobs.Registrar (contributes DeliverWorker + the reconcile periodic) plus the transactional enqueue entry point the outbox drain + redelivery API call. The shared client is injected via SetEnqueuer after jobs.New builds it (two-phase wiring, same as senderidentity).

func NewJobs

func NewJobs(subStore *webhook.SubscriberStore, deliverer Deliverer, webhooks WebhookReader, pool *pgxpool.Pool) *Jobs

NewJobs builds the integration with its dependencies (no client yet). pool backs the periodic reconciler's scan.

func (*Jobs) EnqueueDelivery

func (j *Jobs) EnqueueDelivery(ctx context.Context, pool *pgxpool.Pool, deliveryID string) error

EnqueueDelivery enqueues a River delivery job for an ALREADY-INSERTED pending Layer 2 row, in its own transaction, and stamps job_id. This is for the direct- insert API surfaces that bypass the outbox drain — the /test webhook endpoint and the redelivery API — which create a subscriber_deliveries row targeting a single webhook. Without this, those rows have no River job and (post SubscriberRetryWorker deletion) would never deliver. Idempotent per row via the job_id IS NULL guard under a row lock.

func (*Jobs) EnqueueDeliveryTx

func (j *Jobs) EnqueueDeliveryTx(ctx context.Context, tx pgx.Tx, deliveryID string) (int64, error)

EnqueueDeliveryTx enqueues a delivery job WITHIN the caller's transaction — the outbox pattern: the Layer 2 row insert and this job commit together, so a delivery record can never exist without a job (or vice versa). The caller only calls this when the Layer 2 insert actually inserted a row (dedup ON CONFLICT returned an id), so a deduped event enqueues nothing. Returns the river_job id for the caller to stamp on the Layer 2 row's job_id.

func (*Jobs) ReconcilePending

func (j *Jobs) ReconcilePending(ctx context.Context, pool *pgxpool.Pool) (jobs.ReconcileResult, error)

ReconcilePending enqueues a River delivery job for every pending Layer 2 row that has no live job: job_id IS NULL, or (RescueDeadJobs) stamped with a river_job that is terminal or already pruned. The dead-job arm closes the CW-2 strand: when the final delivery attempt's terminal 'failed' write is lost (a sustained DB outage in exactly that window — the CRITICAL log in DeliverWorker.Work), River discards the job and the row sat 'pending' with a dead job_id, invisible to an IS-NULL-only reconciler, until its TTL. Re-driving is safe: delivery is at-least-once and the DeliverWorker no-ops on rows that already terminalized. The rescue arm is age-gated (RescueWhere, rescueQuietFor) to rows quiet for longer than the full retry envelope, so the per-tick scan never churns the live in-flight set. It runs BOTH at startup (the one-shot cutover from the legacy queue) AND on a live schedule (ReconcileWorker) so a stranded row — from the separate-tx /test/redelivery enqueue paths, an outbox-drain crash window, or a lost terminal write — is re-driven within reconcileInterval (IS-NULL arm) or rescueQuietFor + reconcileInterval (dead-job arm) rather than only on the next restart. A re-run never double-enqueues on the IS-NULL arm; the dead-job arm is at-least-once under concurrent reconcilers (see jobs.ReconcilePending's EvalPlanQual note). Returns the per-arm counts.

func (*Jobs) RegisterJobs

func (j *Jobs) RegisterJobs(w *river.Workers) []*river.PeriodicJob

RegisterJobs adds the DeliverWorker + the reconcile worker, and returns the reconcile periodic. Implements jobs.Registrar.

func (*Jobs) SetEnqueuer

func (j *Jobs) SetEnqueuer(e jobs.Enqueuer)

SetEnqueuer injects the shared client so EnqueueDeliveryTx can insert jobs.

func (*Jobs) WithMetrics added in v1.2.2

func (j *Jobs) WithMetrics(m Metrics) *Jobs

WithMetrics wires the observability backend the DeliverWorker emits the webhook-attempt SLI on. Nil-safe; call before RegisterJobs.

type MaintenanceJobs

type MaintenanceJobs struct {
	// contains filtered or unexported fields
}

MaintenanceJobs is the jobs.Registrar for the webhook janitor: it contributes the MaintenanceWorker and a periodic that fires it on QueueMaintenance. No enqueuer needed — the schedule is the only trigger.

func NewMaintenanceJobs

func NewMaintenanceJobs(sweeper Sweeper) *MaintenanceJobs

NewMaintenanceJobs builds the registrar around a Sweeper (the AutoDisableWorker).

func (*MaintenanceJobs) RegisterJobs

func (m *MaintenanceJobs) RegisterJobs(w *river.Workers) []*river.PeriodicJob

RegisterJobs adds the maintenance worker + its periodic schedule. Mirrors senderidentity's reaper: routed to QueueMaintenance, no UniqueOpts (River's periodic scheduler already inserts at most one per interval and a completed run must not dedup-block the next), RunOnStart:false (first sweep after one interval).

type MaintenanceWorker

type MaintenanceWorker struct {
	river.WorkerDefaults[WebhookMaintenanceArgs]
	// contains filtered or unexported fields
}

MaintenanceWorker runs the janitor passes once per scheduled job. Errors are swallowed inside Tick (logged there); Work returns nil so a transient DB blip never spins River's retry machinery for a best-effort idempotent sweep — the next interval picks it up.

func NewMaintenanceWorker

func NewMaintenanceWorker(sweeper Sweeper) *MaintenanceWorker

NewMaintenanceWorker builds the worker around a Sweeper. Exported so tests can drive Work directly (RegisterJobs builds an identical one for the client).

func (*MaintenanceWorker) Work

type Metrics added in v1.2.2

type Metrics interface {
	// WebhookAttempt records one delivery attempt. outcome ∈ {delivered,
	// retryable_failure, exhausted, webhook_deleted, skipped_disabled};
	// statusClass is "1xx".."5xx" or "none" (no HTTP response).
	// A negative seconds means "count the attempt, record no duration
	// sample" — used for outcomes with no HTTP POST (webhook_deleted,
	// skipped_disabled), which would otherwise drag the duration
	// quantiles toward zero.
	WebhookAttempt(outcome, statusClass string, seconds float64)

	// WebhookTerminal records a delivery only after this worker successfully
	// transitions its row to a terminal state. outcome ∈ {delivered,
	// e2a_failure, endpoint_failure, excluded, webhook_disabled}; scope ∈
	// {initial, replay, test, unknown}.
	WebhookTerminal(outcome, scope string, count int)

	// WebhookDeliveryRescued counts delivery rows the reconciler's dead-job
	// arm re-drove (fresh job replacing a terminal/pruned one). A climbing
	// rate is the poison-row signal.
	WebhookDeliveryRescued(count int)
	// WebhookFirstAttemptLatency records event→first-attempt latency for
	// one subscriber delivery (attempt start − webhook_events.created_at).
	// Observed only on a first-delivery row's FIRST HTTP attempt — retries,
	// replays, and the no-POST outcomes never observe.
	WebhookFirstAttemptLatency(seconds float64)
}

Metrics is the narrow slice of telemetry.Metrics the delivery worker emits (same pattern as internal/janitor.Metrics). Injectable so tests use a fake recorder; satisfied by any telemetry backend.

type ReconcileWorker

type ReconcileWorker struct {
	river.WorkerDefaults[WebhookReconcileArgs]
	// contains filtered or unexported fields
}

ReconcileWorker re-enqueues any pending delivery row with no live River job (never enqueued, or its job is terminal/pruned). It is the LIVE backstop for the separate-tx enqueue paths (/test, redelivery), any outbox-drain crash window, and a lost final-attempt terminal write — turning "recovered only on restart" (or never) into "recovered within reconcileInterval" (dead-job rescues: within rescueQuietFor + reconcileInterval). Never double-enqueues on the IS-NULL arm; the dead-job rescue arm is at-least-once under concurrent reconcilers (see jobs.ReconcilePending's EvalPlanQual note) — a duplicate POST is within the delivery contract.

func (*ReconcileWorker) Work

type Sweeper

type Sweeper interface {
	Tick(ctx context.Context)
}

Sweeper runs the webhook maintenance passes (auto-disable chronically-failing webhooks + clear expired signing_secret_prev rows). Satisfied by *webhook.AutoDisableWorker — the River worker just drives its Tick on a schedule instead of a hand-rolled time.Ticker.

type WebhookDeliverArgs

type WebhookDeliverArgs struct {
	DeliveryID string `json:"delivery_id"`
}

WebhookDeliverArgs carries only the Layer 2 delivery id — the worker reads the payload + webhook from the DB (keeps river_job rows tiny; Layer 2 is source of truth).

func (WebhookDeliverArgs) Kind

func (WebhookDeliverArgs) Kind() string

type WebhookMaintenanceArgs

type WebhookMaintenanceArgs struct{}

WebhookMaintenanceArgs drives the periodic webhook janitor. No fields — the worker sweeps the whole table each run.

func (WebhookMaintenanceArgs) Kind

type WebhookReader

type WebhookReader interface {
	GetWebhookByIDInternal(ctx context.Context, webhookID string) (*identity.Webhook, error)
}

WebhookReader is the narrow webhook-lookup surface the worker needs. *identity.Store satisfies it.

type WebhookReconcileArgs

type WebhookReconcileArgs struct{}

WebhookReconcileArgs drives the periodic stranded-delivery reconciler.

func (WebhookReconcileArgs) Kind

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL