Files
webhooks/docs/retry-mechanism.md
Maxence MaireauxandGitHub c04477b005 feat(webhooks): stop retry storms — terminal 4xx, attempt cap, retention, metrics (#159)
* docs: deep-dive webhooks architecture, prod retry-storm analysis and improvement plan

* fix(worker): treat permanent 4xx as terminal and bound response reads

Non-2xx responses were all retried until the abort-after window elapsed,
so a 403 (bad credentials), 404 (gone) or 405 (wrong method) was retried
for days — the root cause of the production retry storms (Paynovate 403,
ngrok 404, example.com 405).

MakeAttempt now marks 4xx (except 408/429) as 'failed' immediately, honors
an explicit Retry-After on transient responses, and bounds the response
body read to 64 KiB to protect worker memory.

* feat(worker): cap delivery attempts and lower default abort-after

abort-after (default 30d) was the only bound on retries. Combined with the
1m->1h backoff, a dead endpoint was retried ~724 times over 30 days (matching
the observed Tenora retry count) before being marked failed.

Add a --max-attempts hard cap (default 15) enforced by the exponential policy,
and lower the default --abort-after from 30d to 72h. Together they bound a dead
endpoint to a handful of attempts instead of hundreds. The cap is disabled with
--max-attempts=0 for callers that want abort-after-only semantics.

* feat(worker): add attempts retention and orphan reclamation

The attempts table had no retention: success rows were kept forever and
attempts of deleted configs looped in the retry queue indefinitely. In
production this grew the table past 100 GB and dominated cross-AZ RDS cost
(a one-off cleanup cut replication traffic 145GB->0.3GB/15min, ~$39k/yr).

Add a background Retention runner (worker only) that periodically:
  - marks pending attempts of deleted configs as 'failed' (FailOrphanedAttempts)
  - purges 'success'/'failed' attempts past their retention, in bounded batches
Configurable via --retention-period (1h), --retention-success-delay (30d) and
--retention-failed-delay (90d); a 0 delay disables that purge.

* feat: add OpenTelemetry metrics for webhook delivery

The service emitted only traces and logs, so retry storms were detected from
the AWS bill rather than a dashboard. Add OTLP metrics, wired into both the
serve and worker commands:
  - webhooks_delivery_attempts_total{status,status_class} counter
  - webhooks_delivery_duration_seconds histogram
  - webhooks_retry_queue_depth observable gauge (the leading storm indicator)
Delivery attributes are kept low-cardinality; per-endpoint breakdown stays in
the otelhttp delivery spans to avoid a label explosion across customer endpoints.

* fix: harden retention and Retry-After handling (self-review)

Self-review findings before opening the PR:
- NewRetention: guard against a zero/negative period (time.NewTicker panics
  on <= 0 if --retention-period=0 is passed explicitly)
- FailOrphanedAttempts: update in bounded batches — the production orphan
  backlog reached millions of rows and a single UPDATE would hold locks and
  produce one giant transaction
- Purge/orphan batch subqueries: FOR UPDATE SKIP LOCKED so concurrent
  retention runners (multiple worker replicas) do not contend on the same rows
- parseRetryAfter: clamp endpoint-controlled Retry-After to 6h so an absurd
  value cannot park a delivery years in the future

* fix(ci): adapt e2e retry spec to terminal 4xx and harden metrics lifecycle

The e2e 'Retries' spec drove its retry scenario with a 404, which the
terminal-4xx change now fails immediately — the spec timed out waiting for
'to retry' attempts. Switch the retry scenario to a transient 500 and add a
dedicated spec asserting that a permanent 4xx fails immediately without ever
entering the retry queue.

Also addresses review findings on the queue-depth gauge:
- only register the DB-backed gauge when a metrics exporter (or in-memory
  metrics) is configured — the otlpmetrics module installs a periodic reader
  even with the no-op exporter, which would run the COUNT every collect
  interval for a value nobody reads
- stop closing the store from the worker lifecycle hook (bunconnect owns the
  DB) and register otlpmetrics after postgres in serve, so the final metrics
  flush no longer hits a closed database
- add direct Postgres tests for PurgeFinishedAttempts (cutoffs, disabled
  retention, batch draining) and FailOrphanedAttempts (orphan reclamation,
  live-config preservation, terminal rows untouched)

* fix(webhooks): harden retry retention safeguards

* test(webhooks): cover retry delivery after endpoint recovery

* fix(webhooks): bound retry-after by retry window

* fix(webhooks): address retry review feedback

* fix(webhooks): close retry window loopholes

* fix(webhooks): address retry review followups

* test(webhooks): cover retry store failure paths

* fix(webhooks): keep retry history during retention
2026-06-15 11:01:05 +02:00

8.1 KiB

Retry Mechanism

Overview

The retry mechanism processes failed webhook delivery attempts. A background worker (the Retrier) polls the database at regular intervals, claims a batch of pending retries, executes the HTTP calls, and updates their status.

Architecture

Statuses

Status Description
success Webhook delivered successfully (HTTP 2xx)
to retry Delivery failed, scheduled for retry with a next_retry_after timestamp
retrying Claimed by a worker, currently being processed
failed Max retry duration exceeded, permanently failed

Lifecycle

[new message] --> MakeAttempt --> HTTP call
                                    |
                            success? --> "success"
                            failure? --> "to retry" + next_retry_after
                                              |
                                    [retrier tick] --> claim ("retrying")
                                              |
                                        HTTP call (30s timeout)
                                              |
                                    success? --> "success"
                                    failure? --> "to retry" (retry again later)
                                    max delay? --> "failed"

Worker Behavior

Tick Loop

The Retrier runs in a single goroutine with the following loop:

  1. Recover stale claims (once per minute) -- Reset attempts stuck in retrying for more than 5 minutes (from crashed workers) back to to retry. This runs at most once per minute to avoid unnecessary database load.
  2. Claim a batch -- Atomically set up to --retry-batch-size (default: 50) distinct webhook IDs from to retry to retrying using a single CTE query. Oldest retries are claimed first (ORDER BY next_retry_after ASC). Rows already locked by another worker are skipped with FOR UPDATE SKIP LOCKED so workers do not block each other.
  3. Process batch in parallel -- All claimed webhook IDs are processed concurrently via a bounded worker pool (pond), capped at --retry-batch-size concurrent goroutines. For each webhook:
    • Fetch the retrying attempts
    • Unmarshal the payload from the most recent attempt
    • Execute the HTTP call (MakeAttempt) with a 30-second timeout
    • Insert a new attempt record with the result
    • Terminalize the claimed (retrying) attempts; if the new result is retryable, only the newly inserted attempt remains in to retry
    • The worker waits for all goroutines to complete before sleeping
  4. Sleep -- Wait --retry-period (default: 3 seconds), then repeat.

Error Handling

Per-webhook errors are logged and skipped, not fatal. A single failing webhook endpoint does not stop the worker from processing the rest of the batch. Only context cancellation stops the worker.

Errors that are handled gracefully:

  • HTTP call failures (timeout, DNS, connection refused)
  • Malformed payloads
  • Database errors on individual attempts

Claim Query

The claim is atomic and safe for concurrent workers:

WITH to_claim AS (
    SELECT attempts.webhook_id
    FROM attempts
    JOIN configs c ON c.id = attempts.config->>'id'
    WHERE attempts.status = 'to retry'
      AND attempts.next_retry_after < NOW()
      AND c.active = true
      AND NOT EXISTS (
          SELECT 1
          FROM attempts older
          JOIN configs older_c ON older_c.id = older.config->>'id'
          WHERE older.webhook_id = attempts.webhook_id
            AND older.status = 'to retry'
            AND older.next_retry_after < NOW()
            AND older_c.active = true
            AND (older.next_retry_after, older.id) < (attempts.next_retry_after, attempts.id)
      )
    ORDER BY attempts.next_retry_after ASC, attempts.id ASC
    FOR UPDATE OF attempts SKIP LOCKED
    LIMIT $batch_size
),
attempts_to_claim AS (
    SELECT attempts.id
    FROM attempts
    JOIN configs c ON c.id = attempts.config->>'id'
    WHERE attempts.webhook_id IN (SELECT webhook_id FROM to_claim)
      AND c.active = true
      AND attempts.status = 'to retry'
      AND attempts.next_retry_after < NOW()
    FOR UPDATE OF attempts SKIP LOCKED
),
claimed AS (
    UPDATE attempts
    SET status = 'retrying', updated_at = NOW()
    FROM attempts_to_claim
    WHERE attempts.id = attempts_to_claim.id
    RETURNING attempts.webhook_id
)
SELECT DISTINCT webhook_id FROM claimed

When two workers execute this concurrently:

  • Worker A locks the candidate attempt rows and sets them to retrying.
  • Worker B skips rows already locked by Worker A and claims other available rows.
  • No duplicate processing occurs.

Oldest retries are prioritized per webhook with the NOT EXISTS check, then globally across candidate webhooks via ORDER BY next_retry_after ASC, id ASC.

Status Scoping

UpdateAttemptsStatus only modifies attempts in retrying status. Historical attempts (success, failed) are never overwritten. When a retry still needs another retry, the claimed rows are marked failed and the newly inserted attempt carries the next to retry state. This ensures:

  • Accurate audit trail per attempt
  • No accidental overwrites from concurrent Kafka messages creating new attempts for the same webhook

Multi-Worker Scaling

Workers Estimated Throughput
1 ~1,000 webhooks/min
2 ~2,000 webhooks/min
4 ~4,000 webhooks/min
N ~N x 1,000 webhooks/min

Throughput scales linearly because each worker claims its own exclusive batch.

Crash Recovery

If a worker crashes mid-processing, its claimed attempts remain in retrying status. Every worker runs a recovery step at the start of each tick:

UPDATE attempts
SET status = 'to retry', updated_at = NOW()
WHERE status = 'retrying'
  AND updated_at < NOW() - INTERVAL '5 minutes'

This ensures no attempt is permanently stuck. The 5-minute window is chosen to be well above the 30-second HTTP timeout, avoiding false recoveries on slow-but-active requests.

Configuration

Flag Default Description
--retry-period 3s Interval between retry ticks
--retry-batch-size 50 Number of distinct webhook IDs claimed per tick
--min-backoff-delay 1m Minimum delay before retrying a failed attempt
--max-backoff-delay 1h Maximum delay between retries (exponential backoff)
--abort-after 10h Stop retrying after this duration and mark as failed
--max-attempts 15 Stop retrying after this many delivery attempts; with nominal backoff this is about 9h03

Database Indexes

Partial indexes optimize the retry queries:

-- Speeds up ordered retry claims without blocking on unrelated statuses
CREATE INDEX CONCURRENTLY idx_attempts_retry_pending_due
ON attempts (next_retry_after, id, webhook_id)
WHERE status = 'to retry';

-- Speeds up per-webhook oldest-attempt checks and claim expansion
CREATE INDEX CONCURRENTLY idx_attempts_retry_pending_webhook_due
ON attempts (webhook_id, next_retry_after, id)
WHERE status = 'to retry';

-- Speeds up fetching claimed attempts by webhook ID
CREATE INDEX idx_attempts_retrying
ON attempts (webhook_id)
WHERE status = 'retrying';

-- Speeds up the stale recovery query
CREATE INDEX idx_attempts_retrying_recovery
ON attempts (updated_at)
WHERE status = 'retrying';

-- Speeds up fetching the first attempt timestamp for retry-window checks
CREATE INDEX CONCURRENTLY idx_attempts_first_attempt_lookup
ON attempts (webhook_id, created_at);

HTTP Client

The HTTP client used for webhook delivery has a 30-second timeout (pkg/otlp/module.go). This prevents a single slow endpoint from blocking the worker indefinitely and ensures the stale recovery window (5 minutes) is never reached during normal operation.

Backoff Strategy

Uses exponential backoff without jitter (see pkg/backoff/exponential.go):

  • Each retry attempt increases the delay exponentially, starting from --min-backoff-delay.
  • The delay is capped at --max-backoff-delay.
  • After --abort-after total elapsed time since the first attempt, the webhook is marked as failed permanently.