Case studies
Retries and provider failures
How to treat each kind of provider failure differently, with backoff and jitter for transient errors, token cleanup for dead devices, failover, and dead-letter queues for what keeps failing.
Providers fail in several ways. A request might time out, a device token might be dead, a provider might be down, or a template might be broken. Each failure needs a different response. Retry transient errors with backoff and jitter, stop retrying permanent ones, and send anything that keeps failing to a dead-letter queue where an operator can see it.
Concept
A dead-letter queue (DLQ) holds messages that used up their retries. Operators inspect them, fix the cause, such as a bad template or a revoked token, and replay them. Without a DLQ, a bad message either blocks the queue or disappears.
The big idea
Sort each failure into a class before deciding what to do:
| Failure | Response |
|---|---|
| Transient provider error | Retry with exponential backoff and jitter, capped at a set number of attempts, such as 8 |
| Invalid or unregistered device token | Mark the token stale, stop retrying push, and optionally fall back to email |
| Provider outage | Fail over to a secondary provider if one is configured, otherwise park the messages in a delayed queue |
| Template error (a 400-class error) | Send to the DLQ and alert. Never retry forever |
| Max attempts reached | Send to the DLQ and alert |
Infinite retry is the common failure. A template error retried forever wastes capacity and never succeeds.
Walking through it
Backoff spreads retries out in time. The usual pattern is exponential backoff with full jitter: each wait is a random amount up to a ceiling that doubles with every attempt. Jitter stops many failed sends from retrying at the same instant.
import random
def backoff_seconds(attempt, base=1.0, cap=60.0):
# Full jitter: wait a random amount up to an exponentially growing ceiling
ceiling = min(cap, base * 2 ** attempt)
return random.uniform(0, ceiling)
random.seed(7) # fixed seed, so the output is repeatable
for attempt in range(1, 4):
print(attempt, round(backoff_seconds(attempt), 2))Output:
1 0.65
2 0.6
3 5.21The ceiling doubles each time, so attempt 3 can wait up to 8 seconds. Attempt 2 happened to draw a short wait. That randomness is the point. The cap keeps the wait from growing without bound.
Record every attempt. The log entry needs the notification ID, channel, attempt number, provider ID, status, latency and error code. This log powers support tools, and it answers "did the SMS actually leave our system?"
Device tokens need hygiene too. When APNs or FCM says a token is Unregistered or InvalidToken, delete it or mark it immediately. Bind tokens to the user and device, and send to every token a user has, unless the template is device-specific.
Common mistakes
- Retrying a template error. The same bad template fails the same way every time. Send it to the DLQ.
- Retrying without jitter. Clients that failed together retry together, and they overload the provider again.
- Dropping failures silently. Failed messages must land in a DLQ that someone watches.
- Ignoring DLQ depth. A growing DLQ is an alert, not a backlog to ignore.
Check yourself
-
Why does a template error belong in the DLQ and not in the retry loop?
Retrying the same bad template fails the same way. An operator must fix the template, and the DLQ holds the messages until then.
-
Why add jitter to backoff?
Without jitter, failed sends retry at the same moment and hit the provider together again.
Key takeaways
- Classify each failure, and respond to each class in its own way.
- Use exponential backoff with jitter and a capped number of attempts.
- Dead-letter queues hold what keeps failing, and an alert tells someone to look.
- Remove dead device tokens immediately, so you stop sending to them.
Further learning
Next: priority lanes and broadcasts, which covers how urgent messages avoid the backlog.
Related
Resilience patterns
The toolkit for keeping one failure from becoming an outage, from timeouts and retries to circuit breakers, bulkheads, load shedding and cells. Start here for the resilience map.
Message queues
A durable buffer between the code that creates work and the code that does it. Start here for the map of queue concepts.
Case study: notification system
Designing push, email and SMS delivery at scale, covering the pipeline, preference checks, idempotency, provider rate limits, retries and priority lanes.