Client retry strategy
Owner: backend Last updated: 2026-05-27 (#807) Related: ADR-021 (throttler / rate-limit strategy โ salvaged from #822 via #1229 after collision with ADR-020 isolation classification)
When the server says no, why is importantโ
Both 429 and 503 mean "stop sending this for a moment", but the client retry strategy is different:
| Response | Cause | Body code | Retry signal | Client action |
|---|---|---|---|---|
| 429 Too Many Requests | Your client exceeded its per-org or per-IP rate limit. Server is fine; you're being throttled. | code: "rate_limit" | X-RateLimit-Reset (epoch seconds) | Back off until reset, then resume at lower rate. |
| 503 Service Unavailable | Server-side BullMQ queue is full. Not specific to your client โ every client is being shed. | code: "backpressure" | Retry-After (seconds) | Retry after the interval with jittered backoff. |
The body code field exists so client libraries can branch on it programmatically without parsing English error messages.
429 โ rate limit (your fault)โ
Headersโ
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Max requests in the window |
X-RateLimit-Remaining | Always 0 on 429 |
X-RateLimit-Reset | Unix epoch seconds when the window resets |
Retry-After | Seconds until the window resets (same info as X-RateLimit-Reset, easier to consume) |
Bodyโ
{
"statusCode": 429,
"code": "rate_limit",
"error": "ThrottlerException",
"message": "Rate limit exceeded. Back off and retry after X-RateLimit-Reset."
}
Client logicโ
if (response.status === 429) {
const resetEpoch = Number(response.headers.get('X-RateLimit-Reset'));
const waitMs = (resetEpoch * 1000) - Date.now();
await sleep(Math.max(waitMs, 1_000));
return retry();
}
Per-org tracking: 429 fires when your org hits the limit. If you have multiple users in one org, the limit is shared. Coordinate or shard requests across orgs.
503 โ backpressure (server overloaded)โ
Headersโ
| Header | Meaning |
|---|---|
Retry-After | Seconds until you should try again (default 30) |
Bodyโ
{
"statusCode": 503,
"code": "backpressure",
"error": "ServiceUnavailable",
"message": "Inbound queue overloaded; retry after the Retry-After interval. This is server-side congestion, not a per-client rate limit."
}
Client logicโ
if (response.status === 503 && body.code === 'backpressure') {
const retryAfterSec = Number(response.headers.get('Retry-After')) || 30;
// Add jitter so all clients don't slam back at the same moment.
const jitterMs = Math.random() * 5_000;
await sleep(retryAfterSec * 1_000 + jitterMs);
return retry();
}
Important: 503 backpressure is a coarse signal. The server is shedding load across all clients to prevent total collapse. If you keep retrying without backoff you'll just join the herd and make it worse.
Which endpoints can return 429 / 503?โ
429 can return from any endpoint (global OrgThrottlerGuard).
503 backpressure currently applies only to inbound channel webhook endpoints (where BullMQ queue depth matters):
POST /channels/slack/eventsPOST /channels/email/inboundPOST /channels/whatsapp/inboundPOST /channels/telegram/webhook
Vendor webhooks (Slack / Twilio / Telegram) already understand 5xx as "retry later" and back off automatically with their own jitter. Custom HTTP clients should follow the same convention.
Don't try to differentiate without the body codeโ
Some shims look at status code alone:
if (response.status === 429 || response.status === 503) {
retryLater(); // wrong: doesn't account for whether server thinks it's our fault
}
This works for retry but loses signal for alerting. If your client is hitting 429 every minute, that's a client bug (firehose). If it's hitting 503 every minute, that's a server problem. Forward the body code to your observability stack:
metrics.increment(`api.errors.${body.code ?? response.status}`);
Referenceโ
- ADR-021: throttler / rate-limit strategy
- Issue #807: 429/503 differentiation
- Issue #758: BullMQ queue depth alert (the threshold this builds on)