Retries & dead-letter
SchedStack accepts a delivery, then owns it until it reaches a terminal state. A delivery
that fails on a retryable error is retried with exponential backoff. A delivery that can’t
succeed is recorded — as dead_letter or expired — never silently dropped.
This page is the reliability model: the retry policy, what’s retryable vs terminal, the three ways a delivery dead-letters, and how TTL time-caps the whole process. For the operational how-to — listing, inspecting, and replaying failed deliveries — see Dead-letter & replay.
Terminal states
Section titled “Terminal states”Every delivery ends in exactly one of these. All three are durably recorded and queryable.
| State | Meaning |
|---|---|
succeeded |
The endpoint returned a 2xx. |
dead_letter |
Gave up after a terminal response, exhausted attempts, or an exhausted retry budget. |
expired |
The delivery’s TTL deadline passed before it could be (re)delivered. |
Retry policy
Section titled “Retry policy”Retries use exponential backoff. The delay after a failed attempt is:
delay = min(base * factor^attempt, max)where attempt is 0-based (the delay after the first failure uses attempt = 0).
Defaults, applied when you don’t set retry_policy:
| Field | Default | Meaning |
|---|---|---|
max_attempts |
8 |
Total attempts before dead-lettering (1–50). |
base |
5s |
Base delay. |
factor |
2 |
Growth factor per attempt (1–100). |
max |
1h |
Cap on any single backoff delay. |
With the defaults, the delays between attempts are:
attempt 1 fails → wait 5sattempt 2 fails → wait 10sattempt 3 fails → wait 20sattempt 4 fails → wait 40sattempt 5 fails → wait 1m20sattempt 6 fails → wait 2m40sattempt 7 fails → wait 5m20sattempt 8 fails → dead_letter (max_attempts reached)Setting a custom policy
Section titled “Setting a custom policy”Pass retry_policy when you create a schedule. Durations are strings (e.g. "30s", "1h"),
consistent with delay and ttl.
curl -X POST https://api.schedstack.com/v1/schedules \ -H "Authorization: Bearer sk_test_…" \ -H "Content-Type: application/json" \ -d '{ "endpoint": "https://example.com/webhooks/orders", "method": "POST", "body": "{\"order_id\":\"o_123\"}", "delay": "5m", "retry_policy": { "max_attempts": 12, "base": "10s", "factor": 2, "max": "30m" } }'{ "endpoint": "https://example.com/webhooks/orders", "method": "POST", "body": "{\"order_id\":\"o_123\"}", "delay": "5m", "retry_policy": { "max_attempts": 12, "base": "10s", "factor": 2, "max": "30m" }}Validation: max_attempts must be 1–50, factor must be 1–100, and base/max must be
valid duration strings. Omitted fields fall back to the defaults above.
Retryable vs terminal
Section titled “Retryable vs terminal”After each attempt, SchedStack classifies the outcome. Retryable outcomes back off and try again; terminal outcomes dead-letter immediately, without consuming the rest of your attempt budget.
| Outcome | Class |
|---|---|
2xx |
Success — stop. |
408, 429 |
Retryable. |
5xx |
Retryable. |
| Transport fault (timeout, connection refused, DNS/TLS error) | Retryable. |
3xx |
Terminal — redirects are not followed; a 3xx is a misconfiguration. |
4xx (other than 408/429) |
Terminal — retrying a client error won’t help. |
| Blocked address (SSRF guard) | Terminal. |
| Scheme / header-injection rejected (request never sent) | Terminal. |
The three dead-letter triggers
Section titled “The three dead-letter triggers”A delivery moves to dead_letter for exactly one of these reasons:
-
Terminal response. The attempt returned a terminal class (3xx/4xx, blocked address, or a rejected request). Retrying can’t help, so SchedStack stops immediately.
-
Attempts exhausted. A retryable failure occurred on the final attempt — the attempt count reached
max_attempts. -
Retry budget exhausted. Each tenant has a per-endpoint retry budget that caps aggregate retry amplification. When it’s exhausted, further retries dead-letter instead of piling on. Only retries consume budget; first attempts don’t.
Whichever trigger fires, the final attempt — status code, timing, and error — is recorded on the delivery. Nothing disappears.
TTL and expiry
Section titled “TTL and expiry”ttl time-caps the entire process, complementing the count-based max_attempts. When you
set a ttl, the deadline is:
deadline = first_fire_time + ttlA delivery becomes expired when:
- it can’t be initiated before the deadline (e.g. a circuit-breaker deferral for an unhealthy endpoint would push the next attempt past it), or
- a scheduled retry would land after the deadline — SchedStack expires it now rather than firing a request it knows is already too late.
expired is a distinct terminal state from dead_letter, but it’s recorded the same way:
visible, queryable, and never a silent drop.
Everything ends recorded
Section titled “Everything ends recorded”The core guarantee: every accepted delivery reaches succeeded, dead_letter, or
expired, and each one is durable and inspectable. There is no fourth, silent outcome.
To find and act on failed deliveries — list the dead-letter queue, read the attempt history, and replay — continue to Dead-letter & replay.