Retry Pattern with Exponential Backoff
debt(d7/e5/b5/t7)
Closest to 'only careful code review or runtime testing' (d7). Semgrep can flag tight retry loops but most retry-without-backoff issues only surface under load testing or in production incident review.
Closest to 'touches multiple files / significant refactor in one component' (e5). Quick_fix says introduce exponential backoff with jitter, max retry count, and dead-letter destination — that's a retry helper plus rewiring each retry site, not a one-liner.
Closest to 'persistent productivity tax' (b5). Applies across web and queue-worker contexts; once retry/backoff/idempotency policy is established, every integration with external services must conform to it.
Closest to 'serious trap' (t7). Misconception states devs assume immediate retries are equivalent to backoff, and retrying non-idempotent operations (e.g. payment charges) silently doubles side effects — the naive implementation actively worsens the failure mode it tries to fix.
Also Known As
TL;DR
Explanation
Transient failures (network blips, rate limit 429s, database deadlocks) are often self-resolving. The Retry pattern retries the operation automatically, but naive immediate retries can overwhelm a struggling service. Exponential backoff doubles the wait on each attempt (1s, 2s, 4s, 8s...); jitter adds randomness to spread retries from many clients (prevents thundering herd). Best practices: cap maximum retries (3–5), cap maximum delay (30–60s), only retry idempotent operations (GET, PUT — not POST without idempotency keys), use a circuit breaker to stop retrying when the service is clearly down. Guzzle's retry middleware and symfony/http-client both support retry configuration. Log every retry with the delay and reason.
Diagram
flowchart TD
REQ[Send Request] --> TRY{Success?}
TRY -->|Yes| DONE[Done]
TRY -->|No| CHECK{Retryable
error?}
CHECK -->|No - 400 400 auth| FAIL[Fail immediately]
CHECK -->|Yes - 500 429 timeout| WAIT[Wait with<br/>exponential backoff<br/>1s -> 2s -> 4s -> 8s]
WAIT --> ATTEMPTS{Max attempts
reached?}
ATTEMPTS -->|No| REQ
ATTEMPTS -->|Yes| GIVEUP[Give up<br/>alert / dead letter]
style DONE fill:#238636,color:#fff
style FAIL fill:#f85149,color:#fff
style GIVEUP fill:#f85149,color:#fff
style WAIT fill:#d29922,color:#fff
Common Misconception
Why It Matters
Common Mistakes
- Retrying non-idempotent operations without checking — retrying a payment charge double-charges the customer.
- Constant retry interval without exponential backoff — hammers a recovering service at full rate.
- No jitter — all clients retry at the same time, creating a thundering herd.
- No maximum retry limit — infinite retries hold resources and can cause cascading failures.
Code Examples
// Retry without backoff or limit — thundering herd:
for ($i = 0; $i < 10; $i++) {
try {
return $this->http->post('/payments', $data);
} catch (Exception $e) {
sleep(1); // Same interval every time — all clients retry simultaneously
}
}
// Fix: sleep(2 ** $i + random_int(0, 1000) / 1000) — exponential backoff with jitter
// Guzzle retry middleware
$handler = HandlerStack::create();
$handler->push(Middleware::retry(
fn($retries, $req, $res) => $retries < 3 && ($res?->getStatusCode() === 429),
fn($retries) => (int)(1000 * 2 ** $retries) // exponential ms delay
));