Retry Logic & Backoff Fundamentals

Part of: Idempotency Fundamentals & API Guarantees

Distributed systems fail at the network, not in the code. TCP resets, DNS timeouts, and transient 503 responses are not exceptional — they are the steady-state operating environment of any multi-service architecture. Retry logic is the mechanism that converts those transient faults into transparent recoveries, but it introduces its own failure modes: retry storms, duplicate state mutations, and SLA violations caused by compounded latency. This page defines the contract a correct retry strategy must honour, then works through the algorithms and implementation patterns that fulfil it — grounded in the idempotency guarantees that make safe re-execution possible in the first place.


Guarantee Model

A retry policy provides at-least-once delivery at the network boundary. It cannot alone provide exactly-once semantics — that requires idempotent server-side processing anchored by a deduplicated idempotency key. Without that anchor, each retry is a potential duplicate execution.

The contract breaks under three conditions:

  • Partition with state visibility loss. The server received and processed the request but the response was lost in transit. The client cannot distinguish this from a server failure, so it retries — sending a duplicate.
  • Clock skew across nodes. Timeout calculations derived from wall-clock time diverge between caller and callee, causing one side to abandon a request the other is still processing.
  • Thundering herd after partial recovery. Every client whose retry timer fires simultaneously floods the recovering service, re-triggering the failure.

All three are addressed by the algorithms below, but none can be resolved by backoff alone — they require coordinated idempotency enforcement upstream.


Failure Classification: Transient vs. Permanent

Retry logic must strictly separate recoverable network anomalies from deterministic client errors. Retrying a 400 Bad Request is wasteful; retrying a 504 Gateway Timeout is correct.

Retry these status codes:

  • 408 Request Timeout — server-side timeout, safe to retry with backoff
  • 429 Too Many Requests — rate-limited; honour the Retry-After header before re-sending
  • 502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout — upstream unavailability, primary retry targets

Do not retry these:

  • 400 Bad Request, 422 Unprocessable Entity — malformed payload; retrying wastes cycles
  • 401 Unauthorized, 403 Forbidden — credential issue; retrying will not fix it
  • 404 Not Found — resource absent; retrying changes nothing
  • 409 Conflict — state conflict that requires application-level resolution, not blind retry

Treat carefully:

  • 500 Internal Server Error — may indicate state corruption; only retry if the endpoint is verified idempotent and you cap attempts at 2
  • 503 with no Retry-After — apply exponential backoff; the service may be in a degraded rolling-restart state

As covered in HTTP Method Semantics & Safety, GET and HEAD are safe to retry unconditionally. POST, PATCH, and DELETE require explicit idempotency guards before any automatic retry fires.


Core Algorithm: Exponential Backoff with Jitter

The canonical retry algorithm adds an exponentially growing delay between attempts and randomises that delay to prevent synchronised storms. The sequence below is the production-standard implementation.

Step-by-step protocol

  1. Attempt the request.
  2. On a retryable failure, compute the ceiling: cap = min(base × 2^attempt, max_delay).
  3. Sample a jittered wait: wait = random(0, cap) (full jitter) or wait = cap/2 + random(0, cap/2) (equal jitter).
  4. Sleep for wait milliseconds, then repeat from step 1.
  5. After max_attempts, surface the failure to the caller.

Sequence diagram: jittered exponential retry

Exponential Backoff Retry Sequence A client sends three requests to a server. The first two fail (503) and trigger exponentially growing jittered waits. The third succeeds (200). Client Server attempt 1 POST /payments 503 Service Unavailable wait ~340 ms (jittered) attempt 2 POST /payments + Idempotency-Key: abc123 503 Service Unavailable wait ~910 ms (jittered) attempt 3 POST /payments + Idempotency-Key: abc123 200 OK (deduplicated — same key, cached result) idempotent execution

Backoff algorithm comparison

Algorithm Formula Thundering Herd Risk Minimum Wait Preserved Best For
Linear base × attempt High (synchronised) Yes Predictable, low-concurrency jobs
Exponential (no jitter) base × 2^attempt Very high Yes Single-client scenarios only
Full jitter random(0, base × 2^attempt) Minimal No High-concurrency API clients
Equal jitter cap/2 + random(0, cap/2) Low Yes (≥ cap/2) Production payment / fintech APIs
Decorrelated jitter random(base, prev_wait × 3) Minimal Varies AWS SDK–style clients

Equal jitter is the recommended default for production systems: it prevents storms while guaranteeing a meaningful minimum wait that prevents tight retry loops during fast, persistent failures.


Implementation Variants

Variant A: Node.js (async/await, equal jitter)

async function retryWithBackoff(fn, { maxAttempts = 5, baseMs = 200, maxMs = 10000 } = {}) {
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (err) {
      if (!isRetryable(err) || attempt === maxAttempts - 1) throw err;
      const cap = Math.min(baseMs * 2 ** attempt, maxMs);
      const wait = cap / 2 + Math.random() * (cap / 2); // equal jitter
      await new Promise(resolve => setTimeout(resolve, wait));
    }
  }
}

function isRetryable(err) {
  const retryableCodes = new Set([408, 429, 502, 503, 504]);
  return err.status && retryableCodes.has(err.status);
}

Never call setTimeout with a calculated wait synchronously inside a loop without await — this blocks the event loop. The await new Promise(...) pattern releases control correctly.

Variant B: Go (buffered channel concurrency cap)

func retryWithBackoff(ctx context.Context, fn func() error) error {
    base := 200 * time.Millisecond
    maxDelay := 10 * time.Second
    for attempt := 0; attempt < 5; attempt++ {
        err := fn()
        if err == nil {
            return nil
        }
        if !isRetryable(err) || attempt == 4 {
            return err
        }
        cap := base * (1 << attempt)
        if cap > maxDelay {
            cap = maxDelay
        }
        // equal jitter
        wait := cap/2 + time.Duration(rand.Int63n(int64(cap/2)))
        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-time.After(wait):
        }
    }
    return nil
}

Always pass a context.Context so callers can cancel in-flight retry loops. Unbounded goroutine spawning during an outage will exhaust memory; cap concurrent retries with a semaphore channel (make(chan struct{}, maxConcurrent)).

Variant C: Python (tenacity library)

from tenacity import retry, stop_after_attempt, wait_random_exponential, retry_if_exception

@retry(
    retry=retry_if_exception(lambda e: getattr(e, "status_code", 0) in {408, 429, 502, 503, 504}),
    wait=wait_random_exponential(multiplier=0.2, max=10),
    stop=stop_after_attempt(5),
)
def call_payment_api(payload: dict) -> dict:
    response = requests.post("/payments", json=payload, headers={"Idempotency-Key": payload["key"]})
    response.raise_for_status()
    return response.json()

wait_random_exponential implements full jitter. For equal jitter, compose wait_fixed(0.1) + wait_random_exponential(0.1, 10).

Variant D: Java (ThreadPoolExecutor with backoff)

public <T> T retryWithBackoff(Callable<T> task, int maxAttempts, long baseMs, long maxMs)
        throws Exception {
    for (int attempt = 0; attempt < maxAttempts; attempt++) {
        try {
            return task.call();
        } catch (HttpException e) {
            if (!isRetryable(e.getStatusCode()) || attempt == maxAttempts - 1) throw e;
            long cap = Math.min(baseMs * (1L << attempt), maxMs);
            long wait = cap / 2 + ThreadLocalRandom.current().nextLong(cap / 2); // equal jitter
            TimeUnit.MILLISECONDS.sleep(wait);
        }
    }
    throw new IllegalStateException("unreachable");
}

Tune ThreadPoolExecutor max size and queue capacity before deploying. Implement RejectedExecutionHandler (e.g. CallerRunsPolicy) so backpressure degrades gracefully instead of throwing RejectedExecutionException.


Circuit Breakers: Stopping Retry Amplification

Backoff limits individual client load. Circuit breakers limit aggregate load from an entire service fleet. When a downstream service is truly down, hundreds of clients each running 5-attempt backoff loops create an order-of-magnitude amplification of failed requests. The circuit breaker short-circuits all of them.

State machine

Circuit Breaker State Machine Three states: Closed (normal traffic), Open (all requests fail fast), Half-Open (probe traffic allowed). Transitions: error rate exceeds threshold moves from Closed to Open. Timeout expires moves from Open to Half-Open. Probe succeeds moves from Half-Open back to Closed. Probe fails moves from Half-Open back to Open. CLOSED normal traffic OPEN fail fast HALF-OPEN probe traffic error rate > threshold timeout expires probe succeeds probe fails

Standard circuit breaker thresholds for a production API tier:

  • Open threshold: ≥ 50% error rate over a 10-second sliding window, or ≥ 10 consecutive failures
  • Open timeout: 30 seconds before attempting half-open transition
  • Half-open probe budget: 1–5% of traffic; 2 consecutive successes to close

Pair circuit breakers with distributed lock acquisition patterns when the protected resource uses a shared lease — the circuit needs to know whether a failure is in the lock layer or the underlying service.


Idempotency & Deduplication Coordination

Retries are safe only when re-execution produces the same result. This requires the server to recognise and suppress duplicate requests. The mechanism is an idempotency key — a client-generated token attached to every non-safe request and stored server-side for the duration of the retry window.

Distributed lock acquisition sequence

  1. Client attaches Idempotency-Key: <uuid> to the request header.
  2. Gateway or service layer executes: SET idempotency:<key> PROCESSING NX EX 90 in Redis.
  3. If SET returns OK, process the request and overwrite the key with the serialised response: SET idempotency:<key> <response_json> XX EX 90.
  4. If SET returns nil (key exists), poll until the value is no longer PROCESSING, then return the cached response — no re-execution.

The TTL of 90 seconds must exceed the maximum retry window. With a 5-attempt equal-jitter policy (base=200ms, max=10s), the worst-case total retry span is approximately 40 seconds. A 90-second TTL provides a safe 2× margin. For full details see using Redis SETNX for distributed request deduplication.

Deduplication cache TTL alignment

Max retry attempts Max delay cap Worst-case span Recommended TTL
3 4 s ~10 s 30 s
5 10 s ~40 s 90 s
7 30 s ~180 s 300 s

Premature TTL eviction causes re-execution of already-processed requests. Excessive TTLs waste Redis memory and increase cache stampede risk during node restarts. Budget memory as: peak_rps × max_key_size_bytes × ttl_seconds.

Database fallback when cache is unavailable

When Redis is unavailable, fall back to database-level deduplication:

-- PostgreSQL: atomic upsert that returns the existing result on collision
INSERT INTO idempotent_requests (key, response, created_at)
VALUES ($1, $2, now())
ON CONFLICT (key) DO NOTHING
RETURNING id, response;

If the INSERT returns no rows, the key already exists — query the existing row and return its response column. Wrap the check and insert in a single transaction at READ COMMITTED isolation. Use SERIALIZABLE only for financial ledgers where phantom reads could cause double-settlement, as documented in wrapping database transactions for safe retries.


Edge Cases & Failure Scenarios

Failure Scenario Remediation Steps Observability Hooks
Response lost in transit — server processed but client never received 200 Client retries with same idempotency key; server detects existing key and returns cached response without re-executing idempotency.cache_hit_total counter; trace span attribute retry.deduplicated=true
Redis eviction during active retry window Extend TTL to 2× worst-case span; enable maxmemory-policy noeviction for the idempotency keyspace; fall back to DB upsert redis.evicted_keys gauge; alert when idempotency.cache_miss_on_retry_total > 0
Thundering herd: 500 clients retry simultaneously after a 30-second outage Per-client jitter prevents exact synchronisation; add circuit breaker with 50% error threshold to hold clients during recovery; use adaptive concurrency limits (token-bucket per service) http.retry.concurrent_attempts histogram; circuit_breaker.state gauge (0=closed, 1=open, 2=half-open)
Retry-After header absent on 429 Parse X-RateLimit-Reset epoch if present; default to base × 2^attempt with cap; never ignore 429 and retry immediately http.rate_limit.retries_without_header_total counter; log `retry_after_source: header
Idempotency key collision across tenants Prefix keys with tenant ID: tenantId:uuid; enforce namespace separation at the gateway layer idempotency.key_collision_total counter segmented by tenant_id
Clock skew invalidates TTL before retry window closes Synchronise service clocks via NTP (max drift < 500 ms); add 10-second safety margin to all TTL calculations ntp.offset_ms gauge; alert when offset exceeds 200 ms

Operational Concerns

SRE alert thresholds

Instrument these metrics on every service running a retry policy:

  • http.retry.rate — alert when retry attempts exceed 20% of baseline request rate over a 5-minute window
  • http.retry.deduplication_hit_rate — sustained hit rate above 5% indicates clients are retrying more than expected; investigate upstream instability
  • http.backoff.duration_ms (p99 histogram) — alert when p99 exceeds 8,000 ms (approaching the 10-second cap)
  • circuit_breaker.open_duration_seconds — alert when any circuit stays open for more than 60 seconds

Memory and storage budgeting

At 1,000 requests/second with a 128-byte average idempotency key + 512-byte cached response, a 90-second TTL requires:

1,000 rps × 640 bytes × 90 s = ~57.6 MB

With 20% Redis overhead for hash structures: budget 70 MB per service instance. Scale linearly with RPS. For TTL management and eviction strategy details see the dedicated storage patterns page.

Index strategy for database-backed deduplication

CREATE UNIQUE INDEX CONCURRENTLY idx_idempotent_requests_key
    ON idempotent_requests (key);

-- Partial index to skip expired rows (PostgreSQL)
CREATE INDEX CONCURRENTLY idx_idempotent_requests_active
    ON idempotent_requests (key, created_at)
    WHERE created_at > now() - interval '90 seconds';

The partial index keeps the active working set small and prevents full-table scans during high-throughput deduplication checks. Run VACUUM ANALYZE idempotent_requests after TTL-based batch deletes to reclaim dead tuples.


Production Readiness Checklist