API Error Handling and Retry Strategy: Give Your API an Error Language People Can Read
An API error is the most common conversation your backend will have with the outside world, yet many sites treat error handling like a black box: a raw HTML error page, or a bare 500 with no context, leaving the caller to guess. The problem is rarely a single status code — it is the absence of a consistent convention. A well-designed error response tells the caller three things at a glance: which layer failed, why it failed, and whether retrying will help.
Layer Your Errors: Client Fault or Server Fault
| Status | Meaning | Common Triggers | Safe to Retry |
|---|---|---|---|
| 400 | Malformed request | JSON parse failure, missing required field | No |
| 401/403 | Unauthenticated / forbidden | Expired token, bad signature | No (re-send after refresh) |
| 404 | Resource not found | Wrong path, deleted resource | No |
| 422 | Semantic validation failed | Invalid email, insufficient stock | No |
| 429 | Rate limited | Over quota or frequency limit | Yes (honor Retry-After) |
| 500/502/503/504 | Server-side failure | Crash, gateway timeout, upstream down | Yes (bounded) |
A Consistent Error Response Shape
Both 4xx and 5xx responses should use the same JSON structure instead of ad-hoc text.
{
"code": "RATE_LIMIT",
"message": "too many requests",
"request_id": "abc123",
"retry_after": 3,
"details": {"limit": 100, "window": "1m"}
}
code— machine-readable error code the client can branch onmessage— human-readable explanationrequest_id— the key that ties a response to your logsretry_after— how many seconds the client should wait (for 429); far more accurate than the client guessing
Retry Strategy: Not Every Error Deserves a Retry
Retrying is not "call again when it fails." Unbounded retries turn a small fault into an avalanche — the busier the server gets, the harder clients hammer it. Follow two rules: only retry recoverable errors, and always wait between attempts.
| Parameter | Suggested Value | Notes |
|---|---|---|
| Retryable errors | 429, 502, 503, 504 | Never retry 400/401/403/404/422 |
| Initial wait | 1s | Starting point for exponential backoff |
| Backoff factor | 2 | 1s → 2s → 4s → 8s |
| Max wait | 30–60s | Keep the ceiling bounded |
| Retry limit | 3 attempts | Beyond that, fail fast and escalate |
| Jitter | ±20% random | Prevents synchronized retry storms |
Adding jitter to exponential backoff is a tiny change that measurably reduces retry storms. Clients should also honor the Retry-After header from the server — it is more accurate than anything computed locally.
Idempotency Keys: Retry Without Double Ordering
The biggest risk with retrying POST requests is side effects running twice — a user taps "Place order" twice and ends up with two orders. The fix is an idempotency key: the client sends a unique value in a header, the server remembers it, and repeated requests with the same key return the first result.
Idempotency-Key: 9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d
A Real-World Scenario: Three Failed Payment Callbacks
A payment callback endpoint started returning 502s one afternoon. The provider's retry mechanism fired three times, all failed, and the jobs landed in a dead-letter queue. The on-call engineer followed the same request_id through the logs and traced it to the gateway — one upstream machine had run out of memory. Because the error shape was uniform and request_id was everywhere, the root cause took about fifteen minutes to find. With messy ad-hoc error text, that investigation would have taken at least three times as long.
What Else the Client Should Do
- Encapsulate retries in the gateway or SDK layer; do not scatter
forloops across business code - Add a circuit breaker: after a threshold of consecutive failures (e.g., 5), fail fast for a short window instead of sending requests
- Separate connect and read timeouts (default 5s/10s) so a slow endpoint does not stall the whole page
Error Code Naming and API Documentation Conventions
A consistent structure is not enough; the codes themselves should be readable. Name them as "domain + scenario + state," e.g. ORDER_INSUFFICIENT_STOCK, PAYMENT_CARD_DECLINED, AUTH_TOKEN_EXPIRED — far easier to reason about than a bare number like 1001. Once a code ships, keep it stable; clients typically hard-code branches on specific codes, and renaming breaks compatibility.
Document every error code in your API docs: what triggers it, the message template, whether it is retryable, and whether a retry_after applies. Generate a single "error code reference" that both frontend and backend treat as the source of truth. In practice, many teams put the error-code table at the top of the OpenAPI spec and add automated tests asserting that every 4xx returns the same shape and carries a request_id.
One more convention that is often skipped: tie logs to errors. Every error response should let you trace from the request_id back to backend logs, so a screenshot of a user's error can be investigated quickly. Inject request_id in an entry middleware, propagate it downstream, and you get a complete call chain.
Reference: Stripe API error handling https://docs.stripe.com/api/errors
Reference: Google Cloud API error model https://cloud.google.com/apis/design/errors
Reference: RFC 6585 (429 Too Many Requests) https://www.rfc-editor.org/rfc/rfc6585