Skip to main content

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:

ResponseCauseBody codeRetry signalClient action
429 Too Many RequestsYour 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 UnavailableServer-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โ€‹

HeaderMeaning
X-RateLimit-LimitMax requests in the window
X-RateLimit-RemainingAlways 0 on 429
X-RateLimit-ResetUnix epoch seconds when the window resets
Retry-AfterSeconds 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โ€‹

HeaderMeaning
Retry-AfterSeconds 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/events
  • POST /channels/email/inbound
  • POST /channels/whatsapp/inbound
  • POST /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)