Skip to content

Commit a21290e

Browse files
yanxue06cursoragent
andcommitted
docs(webhooks): document retry policy (exponential backoff + jitter + configurable cap)
Updates Delivery and retries to reflect the formula-driven retry schedule landing in photon-hq/spectrum-webhook — exponential backoff with ±50% jitter, four operator-tunable knobs (initial delay, growth factor, per-attempt cap, total attempts), and an explicit honest statement that there is no DLQ today. Customer-facing changes: - Contract bullet now mentions jitter and the ~9.3s worst-case sleep budget alongside the existing ~6.2s expected case. - Retry-policy table grows an "Actual jittered range" column so customers know to expect [100ms, 300ms), [500ms, 1500ms), etc. - New "Why jitter matters" section explains the thundering-herd failure mode that motivates the design. - New "Tunable on our side" section lists the four env knobs as operator-only, with a pointer for customers who need different retry behaviour (issue / Discord). - Dedupe-key advice now points explicitly at X-Spectrum-Webhook-Id plus payload.message.id rather than naming the variables locally. PR body intentionally references the webhook-side PR placeholder; the cross-link gets filled in after both PRs are open. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent fb979b0 commit a21290e

1 file changed

Lines changed: 38 additions & 15 deletions

File tree

webhooks/delivery.mdx

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@ You know what arrives ([Events](/webhooks/events)) and how to prove it's real ([
77

88
## The contract at a glance
99

10-
- **Strong retry behaviour.** Up to 4 attempts per event, with backoff on `5xx`, `408`, `429`, network errors, and worker-side timeouts. The vast majority of deliveries land on attempt 1; the retries are there for the occasional bad minute on your side.
10+
- **Strong retry behaviour.** Up to 4 attempts per event by default, with exponential backoff plus jitter on `5xx`, `408`, `429`, network errors, and worker-side timeouts. The vast majority of deliveries land on attempt 1; the retries are there for the occasional bad minute on your side.
1111
- **Fast acknowledgement.** Any `2xx` ends it — the worker stops as soon as your server says ok.
1212
- **Fast permanent failure.** Other `4xx` codes (`400`/`401`/`404`/etc.) are treated as fatal — we don't waste your retry budget when the request will never succeed.
13-
- **Bounded budget.** 30-second per-attempt timeout, with ~6.2 seconds of backoff sleeps between attempts. If your server is still down after the final attempt, the event is logged and the worker moves on.
13+
- **Bounded budget.** 30-second per-attempt timeout, with up to ~9.3 seconds of backoff sleeps between attempts (jittered). If your server is still down after the final attempt, the event is logged and the worker moves on — there is no dead-letter queue today.
1414
- **At-least-once delivery.** A retry after your server timed out can re-deliver an event you already processed — always dedupe in your handler (see [Be idempotent](#be-idempotent) below).
1515
- **URL guard, fail-closed.** Before every attempt the worker validates the target URL: it must be `https://`, must resolve to a public address, and must not redirect. A URL that fails the check is dropped immediately — fatal, no retry — see [Where we won't deliver](#where-we-wont-deliver) below.
1616

@@ -37,34 +37,57 @@ sequenceDiagram
3737
3838
W->>Y: POST — attempt 1
3939
Y-->>W: 503
40-
W->>W: wait 200ms
40+
W->>W: wait ~200ms (±50%)
4141
W->>Y: POST — attempt 2
4242
Y-->>W: 503
43-
W->>W: wait 1s
43+
W->>W: wait ~1s (±50%)
4444
W->>Y: POST — attempt 3
4545
Y-->>W: 200 OK
4646
Note over W: ✓ delivered after retry
4747
```
4848

49-
The backoff *sleeps* total ~6.2 seconds (200ms + 1s + 5s). Wall-clock time also includes per-attempt network time, bounded by the 30-second per-attempt timeout: a healthy delivery finishes in milliseconds, while a worst case where every attempt hangs to the timeout can run up to ~2 minutes before the worker gives up. It stops as soon as it gets a 2xx or determines further retries are pointless.
49+
The backoff *sleeps* sum to ~6.2 seconds in the average case (200ms + 1s + 5s) and ~9.3 seconds in the worst case (jitter ceiling). Wall-clock time also includes per-attempt network time, bounded by the 30-second per-attempt timeout: a healthy delivery finishes in milliseconds, while a worst case where every attempt hangs to the timeout can run up to ~2 minutes before the worker gives up. It stops as soon as it gets a 2xx or determines further retries are pointless.
5050

5151
## Retry policy
5252

53-
| Attempt | Delay before this attempt |
54-
| --- | --- |
55-
| 1 | none — fires immediately |
56-
| 2 | 200ms after attempt 1 ends |
57-
| 3 | 1 second after attempt 2 ends |
58-
| 4 | 5 seconds after attempt 3 ends |
53+
Retries follow an exponential-backoff schedule with ±50% jitter applied to every delay. The formula is the canonical [full-jitter pattern](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/) — the *expected* delay is the value in the table below, while the *actual* delay is drawn uniformly from the jitter window so coordinated retries don't pile onto your endpoint at the same instant after a recovery.
54+
55+
| Attempt | Expected delay before this attempt | Actual jittered range |
56+
| --- | --- | --- |
57+
| 1 | none — fires immediately ||
58+
| 2 | 200ms after attempt 1 ends | `[100ms, 300ms)` |
59+
| 3 | 1 second after attempt 2 ends | `[500ms, 1500ms)` |
60+
| 4 | 5 seconds after attempt 3 ends | `[2.5s, 7.5s)` |
5961

60-
Per-attempt timeout: **30 seconds** (configurable via `DELIVERY_TIMEOUT_MS` on our side). Treat it as a hard ceiling, not a target — acknowledge in well under a second and push slow work off the response path (see [Acknowledge fast](#acknowledge-fast-process-asynchronously) below).
62+
Per-attempt timeout: **30 seconds**. Treat it as a hard ceiling, not a target — acknowledge in well under a second and push slow work off the response path (see [Acknowledge fast](#acknowledge-fast-process-asynchronously) below).
6163

6264
After attempt 4 fails, the event is logged and dropped. There is no persistent queue and no dead-letter destination — both are out of scope for v1.
6365

6466
<Note>
6567
Multiple registered URLs receive the same event in parallel via `Promise.allSettled`. One slow or failing URL never delays delivery to the others.
6668
</Note>
6769

70+
### Why jitter matters
71+
72+
A naive deterministic schedule (`200ms, 1s, 5s` to the millisecond) means that when *every* project's deliveries flap at once — a rolling deploy on your side, a regional DB failover, a noisy upstream — every retry across every project queues at exactly the same offsets and lands on your first healthy moment as a coordinated herd. Jitter spreads each scheduled delay across a window twice as wide as the expected value, so the retry volume smears out and your connection pool / WAF / autoscaler get room to absorb the load gracefully.
73+
74+
### Tunable on our side
75+
76+
The retry schedule is operator-configurable. The Photon team can adjust these knobs per environment to trade latency for durability — useful, for example, if a regulated workload needs to tolerate a longer outage than the default ~6.5s budget covers. The full set:
77+
78+
| Knob | Default | Effect |
79+
| --- | --- | --- |
80+
| Initial delay | 200ms | The `i = 0` term — delay before the first retry. |
81+
| Growth factor || Multiplier applied per retry index (`200ms → 1s → 5s → ...`). |
82+
| Per-attempt cap | 10 seconds | Ceiling applied to every computed delay before jitter, so the curve can't run away. |
83+
| Total attempts | 4 (initial + 3 retries) | Higher values trade wall-clock latency for more retries against a flaky endpoint. |
84+
85+
These are *internal* env vars on the spectrum-webhook worker — customers can't set them per-webhook today. If you have a use case that needs different retry behaviour (more retries, longer ceiling), reach out and we'll discuss tuning the deployment-wide defaults or adding a per-project override. Open an issue on the [docs repo](https://github.com/photon-hq/docs) or message us in the [Discord](https://discord.gg/4c3VJzDfNA).
86+
87+
<Tip>
88+
If you're seeing duplicates after long handler waits — say, attempt 1 takes 28 seconds and succeeds on your side, but our retry layer doesn't see the response in time — that's the per-attempt timeout, not the retry schedule. Tighten your handler (acknowledge first, process later) before asking us to widen our budget.
89+
</Tip>
90+
6891
## What your status codes mean to us
6992

7093
| Status code(s) | Worker treats as | Result |
@@ -118,7 +141,7 @@ If your handler takes >30 seconds, the worker will time out the connection, mark
118141

119142
### Be idempotent
120143

121-
At-least-once delivery means the same event can arrive more than once if your server hung after processing but before responding. Dedupe in your handler:
144+
At-least-once delivery means the same event can arrive more than once if your server hung after processing but before responding. Dedupe in your handler using a composite of the `X-Spectrum-Webhook-Id` header (the webhook config ID) and an event-scoped identifier from the payload — e.g. `payload.message.id` for the `messages` event:
122145

123146
```ts
124147
const dedupeKey = `${webhookId}:${payload.message.id}`;
@@ -131,7 +154,7 @@ await processOnce(payload);
131154
await markProcessed(dedupeKey);
132155
```
133156

134-
A short TTL (24-48 hours) on the dedupe table is enough — by then the worker has long since moved on.
157+
A short TTL (24-48 hours) on the dedupe table is enough — the retry budget is bounded to under a minute even with jitter, so anything we'd re-deliver lands well inside that window.
135158

136159
### Handle bursts
137160

@@ -153,7 +176,7 @@ Returning `503` on overload is fine — we'll back off and retry. But it eats in
153176
| Webhook URL is `http://` (not HTTPS) | Dropped immediately by the URL guard, no retry. Every event lost until you re-register an `https://` URL. |
154177
| Webhook URL resolves to a private/internal IP | Dropped immediately, no retry (SSRF guard). Logged. |
155178
| Endpoint responds with a `3xx` redirect | Dropped immediately, no retry. Register the final URL instead. |
156-
| Endpoint down for >6 seconds | Dropped after 4 attempts. Event lost. |
179+
| Endpoint down for the full retry window (~6.5s default, more if you've requested tuning) | Dropped after the final attempt. Event lost — no DLQ today. |
157180
| Spectrum worker crashes mid-delivery | Event lost — no durable queue. Subsequent events resume after restart. |
158181

159182
The "event lost" rows are why this is **at-least-once, with bounded retries**, not "guaranteed delivery." If your use case requires zero loss (financial transactions, audit logging), pair webhooks with periodic reconciliation against the [Spectrum API](/api-reference/introduction) — list messages on the space and backfill anything you missed.

0 commit comments

Comments
 (0)