* 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
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:
- Recover stale claims (once per minute) -- Reset attempts stuck in
retryingfor more than 5 minutes (from crashed workers) back toto retry. This runs at most once per minute to avoid unnecessary database load. - Claim a batch -- Atomically set up to
--retry-batch-size(default: 50) distinct webhook IDs fromto retrytoretryingusing a single CTE query. Oldest retries are claimed first (ORDER BY next_retry_after ASC). Rows already locked by another worker are skipped withFOR UPDATE SKIP LOCKEDso workers do not block each other. - Process batch in parallel -- All claimed webhook IDs are processed concurrently via a bounded worker pool (
pond), capped at--retry-batch-sizeconcurrent goroutines. For each webhook:- Fetch the
retryingattempts - 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 into retry - The worker waits for all goroutines to complete before sleeping
- Fetch the
- 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-aftertotal elapsed time since the first attempt, the webhook is marked asfailedpermanently.