Never Hand-Roll Retry Logic
Your retry loop is retrying errors that can never succeed. A 500 will probably clear on the next attempt; a 400, 401, or 422 will fail identically every single time — so a naive for attempt in range(5) loop just multiplies the same doomed request by five, hammers a struggling service, and burns your latency budget for nothing.
Why hand-rolled retries go wrong
The three-line retry loop everyone writes gets the easy part right and the hard parts wrong:
- No backoff. Retrying instantly stacks requests onto a service that is already failing — the classic retry storm that turns a blip into an outage.
- No jitter. Even with backoff, every client that failed at the same moment retries at the same moment. Randomized delay ("jitter") spreads them out so they don't collide again.
- No error triage. The loop retries whatever it caught, including
4xxclient errors — bad input, missing auth, validation failures — that are your fault and will never pass on a retry.
Backoff math, jitter, and circuit-breaker state are subtle enough that a battle-tested library will do it better than the code you write under deadline.
The rule: retry transient, skip client errors
| Status | Meaning | Retry? |
|---|---|---|
408, 429, 500, 502, 503, 504 | Timeout, rate-limit, server error | Yes — transient |
| Connection reset / DNS / socket timeout | Network blip | Yes — transient |
400, 401, 403, 404, 422 | Bad request, auth, not-found, validation | No — permanent |
The one exception worth knowing: 429 Too Many Requests is a 4xx but it is retryable — honor its Retry-After header instead of retrying blindly.
Reach for a library
Python — tenacity:
from tenacity import (retry, stop_after_attempt,
wait_random_exponential, retry_if_exception_type)
@retry(
retry=retry_if_exception_type(TransientError), # skip 4xx
wait=wait_random_exponential(multiplier=1, max=60), # backoff + jitter
stop=stop_after_attempt(5),
)
def call_api():
...
wait_random_exponential gives you exponential backoff and jitter in one strategy; retry_if_exception_type is where you gate on transient-only errors.
JavaScript — p-retry: throw AbortError to stop immediately on a client error, without consuming the remaining attempts.
import pRetry, {AbortError} from 'p-retry';
await pRetry(async () => {
const res = await fetch(url);
if (res.status >= 400 && res.status < 500 && res.status !== 429) {
throw new AbortError(res.statusText); // permanent — stop now
}
if (!res.ok) throw new Error(res.statusText); // transient — retry
return res.json();
}, {retries: 5, factor: 2});
Power section: make retries actually safe
A retry re-sends a request the server may have already processed. That is only safe if the operation is idempotent — running it twice has the same effect as running it once.
GET,PUT, andDELETEare idempotent by design.POSTusually is not.- For non-idempotent writes, send an idempotency key (a unique header/field per logical operation) so the server dedupes a re-sent request instead of charging the card twice.
- Bound everything. Cap attempts (
stop_after_attempt) and total wait (max), so a dead request stops failing on a loop instead of retrying forever. - Add a circuit breaker in front of the retry for high-volume calls: once a dependency is clearly down, fail fast for a cooldown window instead of letting every caller retry into the void.
Retry the transient, skip the 4xx, key your writes, and bound the loop — then let a library carry the math.
Resources
- tenacity — retrying library for Python —
wait_random_exponential,stop_after_attempt,retry_if_exception_type. - p-retry — retry a promise-returning function —
retries,factor, andAbortError. - MDN — HTTP response status codes — the
4xxvs5xxsplit. - MDN — Retry-After header — for
429and503. - Idempotency methods (MDN glossary) — which HTTP methods are safe to re-send.
<div class="cta">
Building an AI feature? Yeda AI designs, audits, and ships production LLM systems.
</div>