Reliability

Retry and Timeout Best Practices:
7 Rules for Resilient Calls

Most teams get every individual retry policy right and still amplify a 30-second degradation into a 40-minute incident. Retries compose multiplicatively across layers, and no single code review ever sees the product.

9 min read · 8 Principles ·
Beige paper sculpture with twisted ribbon-like folds forming a helical column on a neutral background.

TL;DR

  • A remote call without an explicit timeout is an unbounded commitment of one of your threads to someone else's availability.
  • A timeout configured on a call is a local guess. Only a deadline that travels with the request keeps the whole chain honest about how much time is left.
  • Retrying a 400 or a 422 is not resilience, it is a loop - retries belong only to failures a later attempt could plausibly resolve.
  • Fixed-interval retries synchronize your clients into a herd that arrives together at the moment the dependency is weakest.
  • Any retry of an operation that charges, creates, or sends is a duplicate unless an idempotency key collapses the second attempt into the first.
  • A circuit breaker is a load-shedding device, not a health check, and it is worthless without a defined answer to what the caller returns while it is open.
  • Retries at two layers multiply, so retry ownership has to be assigned once per dependency across a call chain no single team can see.

What Retry and Timeout Discipline Actually Prevents

Timeouts, retries, and circuit breakers are the three controls that decide whether a degraded dependency stays a degraded dependency or becomes an outage. They are usually treated as three independent settings on a client. They are one budget: the caller's deadline, divided among attempts, spent by whichever layer gets to it first.

The reason this goes wrong at scale is arithmetic. A gateway that retries three times, calling a service whose HTTP client retries three times, calling a driver that retries twice, produces eighteen attempts for one user request. Every layer's policy is defensible in isolation. The product is a self-inflicted denial of service delivered to a dependency that was already struggling. None of the seven principles below are about individual carelessness - they are about the fact that resilience is a property of the whole call chain, and the call chain is not owned by anyone.

1. Every Outbound Call Needs an Explicit Timeout

The incident does not look like a timeout problem. It looks like your service becoming unresponsive while its dependency is merely slow: threads accumulate in a wait state, the connection pool drains, healthy requests queue behind sick ones, and your latency graph goes vertical while the dependency's error rate stays flat. Set both a connection timeout and a request or read timeout on every HTTP client, gRPC channel, database client, cache client, broker client, and third-party SDK. Never inherit the library default - several of the most widely used ones are unbounded, which makes your service's default policy "wait forever."

ANTI-PATTERN — the library default is unbounded
resp = requests.post(f"{BILLING_URL}/charges", json=payload)
# no default timeout: this can hang for the life of the socket
CORRECTED — connect and read bounded separately
resp = requests.post(
    f"{BILLING_URL}/charges",
    json=payload,
    timeout=(1.0, 4.0),  # (connect, read)
)

A good starting value is the downstream service's p99.9 latency plus a margin, the approach Amazon documents for choosing an acceptable rate of false timeouts. The tradeoff is real: a tight timeout converts slow successes into failures, and if your dependency's p50 sits close to its p99.9 you will spend engineering time tuning this rather than picking a number once.

2. A Deadline Travels With the Request

Without propagation, service C happily begins four seconds of work for a request service A abandoned two seconds ago. You pay full capacity for results nobody will read, and the wasted work is invisible on every dashboard because from C's perspective the request succeeded. Pass the caller's remaining deadline or cancellation context into every downstream call, and check it before starting further work.

ANTI-PATTERN — every hop restarts the clock
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
// discards whatever budget the original caller had left
CORRECTED — the caller's remaining budget is passed through
resp, err := inventory.CheckStock(ctx, req)
// ctx carries the original deadline; gRPC sends the remainder
// on the wire as grpc-timeout

gRPC makes deadline propagation the default in Go and Java, and the gRPC team's guidance is unambiguous: always set one, and let it travel. In HTTP services the equivalent is a request-scoped context with an absolute deadline, threaded through rather than reconstructed at each hop. Diagnostic worth running: pick your slowest endpoint and name the service that will still be working on it thirty seconds after the browser gave up. If you cannot, deadlines are not propagating.

3. Retry Only Failures That Can Succeed Later

Retrying a 400, 401, 403, 404, 409, or 422 is not resilience; it is a loop that turns one client bug into sustained load against an endpoint that already gave you the answer. The signature in production is a rate-limit alert on an endpoint whose legitimate traffic has not changed. Restrict retries to timeouts, connection failures, and explicitly retryable statuses - 429, 502, 503, 504 - and give every policy a maximum attempt count.

CORRECTED — bounded, and only on retryable classes
const RETRYABLE = new Set([429, 502, 503, 504]);
for (let attempt = 0; attempt < 4; attempt++) {
  try { return await client.send(cmd); }
  catch (e) {
    if (!RETRYABLE.has(e.statusCode) && !isTransport(e)) throw e;
    await sleep(delayFor(attempt, e.headers?.["retry-after"]));
  }
}
throw new RetriesExhausted();

Unbounded retry loops, while-true with a sleep, and recursive retries with no depth limit all belong to one category: a policy with no upper bound on the load it can generate. When a server tells you when to come back, believe it - honour Retry-After or the provider's documented rate-limit reset header rather than substituting your own schedule.

The cost is that a genuinely transient 409 on a contended resource will now surface to the caller instead of resolving quietly. That is the correct trade: a conflict is a business outcome, and the caller is the layer that knows whether re-reading and re-submitting is safe.

4. Backoff Without Jitter Rebuilds the Herd

Every caller observed the failure at roughly the same time, so every caller retries at roughly the same time. The dependency recovers, absorbs a synchronized burst, and fails again - a recovery oscillation that looks like the dependency flapping when in fact your fleet is beating it back down. Compute delays with exponential growth plus randomized jitter, and cap the maximum delay.

CORRECTED — full jitter with a capped ceiling
const CAP_MS = 20_000, BASE_MS = 100;
const delayFor = (attempt) =>
  Math.random() * Math.min(CAP_MS, BASE_MS * 2 ** attempt);

AWS's exponential backoff and jitter analysis is the canonical treatment, and it shows full jitter outperforming both fixed and unjittered exponential schedules on total work and completion time. The same reasoning applies well beyond retries: scheduled jobs that all fire at the top of the hour are a herd nobody thinks of as one.

Rule of thumb: if you can predict the exact second your fleet's next retry wave will land, so can your dependency's saturation curve.

5. Non-Idempotent Retries Need an Idempotency Key

The nastiest version of this failure is invisible from the client side: the first attempt succeeded, the response was lost on the way back, the client saw a timeout and retried. Nothing in your logs says "double charge." The customer's statement does. Send an idempotency key derived from a stable business identifier on any operation with a non-reversible effect, and never retry such an operation without one.

ANTI-PATTERN — a fresh key per attempt defeats the mechanism
// inside the retry loop
headers.set("Idempotency-Key", UUID.randomUUID().toString());
CORRECTED — the key is derived once, outside the loop
String key = "charge:" + order.getId() + ":" + order.getVersion();
headers.set("Idempotency-Key", key);  // stable across attempts

Stripe's idempotent requests model is the reference implementation worth copying: the server stores the status code and body of the first request under the key and replays it for every later attempt, including failures. If you are building the receiving end, this is a contract obligation rather than a client convenience - it belongs in the spec alongside the rest of your public API design standards.

6. Circuit Breakers Are Load Shedders, Not Health Checks

Timeouts and retries alone cannot stop a failing dependency from consuming your capacity: they bound each call, but you still pay the full timeout on every one, and under sustained failure that is enough to exhaust your own threads. Wrap each remote dependency in a breaker with an explicit failure threshold, open interval, and half-open trial policy - Martin Fowler's circuit breaker writeup remains the clearest statement of the three-state machine. Then do the part most implementations skip: define in code what the caller returns while the circuit is open.

A cached value, a queued write, a reduced response, a declared error: anything except an unhandled exception propagating to a user. Pair the breaker with a bounded pool or semaphore per dependency, so one saturated dependency cannot consume the execution capacity every other dependency shares. At the infrastructure layer, Envoy's outlier detection does the host-level version of the same job.

Breakers make testing harder and can extend recovery time - a breaker still open on a dependency that has already recovered is its own small outage. That is what the half-open trial is for, and why breaker state transitions belong in a metric rather than a log line.

7. Retry Ownership Belongs to One Layer

This is the principle that makes the previous six insufficient on their own. Every team implemented a defensible policy. The gateway retries because gateways should be forgiving. The service retries because the SDK made it a one-line config. The driver retries because that is its default. Eighteen attempts leave the building for one user request, and every review that approved a piece of it was correct.

Assign retry responsibility to exactly one layer per outbound dependency, configure the layers above and below it not to retry, and bound the total retry budget - the sum of attempt timeouts and backoff delays - below the timeout of the operation containing it. A budget that can exceed the caller's deadline is not a retry policy; it guarantees the caller times out before the last attempt lands, wasting every attempt after the first.

Diagnostic worth running: name the one layer that retries calls to your payment provider. If two people give different answers, you already have a multiplier in production. This is the same class of problem as microservice boundary discipline - locally correct, globally wrong, and visible only from a view of the whole codebase.

How Pandorian Enforces This Guide

Every principle above resolves to a shape in code: a client constructed without a timeout argument, a retry loop with no attempt ceiling, a fixed sleep where a jittered delay should be, an idempotency key generated inside the loop it is supposed to span. That makes them enforceable rather than aspirational.

Caught at PR level

A new HTTP client without an explicit timeout, a retry policy that catches a 4xx, or a fixed-interval sleep in a retry path surfaces with the line, the rule, and a suggested fix - while the author still has the context to act on it.

Full scans

An on-demand scan across the whole codebase sets the day-one baseline, and it is the only surface that sees cross-layer problems. A gateway retry policy and a service retry policy that are individually fine and jointly a 9x multiplier live in different repositories and never appear in the same diff.

Scheduled scans

The same full scan on a cadence, catching drift introduced outside any PR: an SDK upgrade whose default retry behaviour changed, a dependency added to a shared client factory, a breaker threshold tuned during an incident and never reverted.

A Taste of the Guideline Package

Four of the checks that ship in this package - each runs at the surface where it catches the problem earliest.

RULE WHAT IT FLAGS
REL_32315
An HTTP, gRPC, database, cache, or broker client built without an explicit connection and request timeout
REL_36759
A retry path that catches a 400, 401, 403, 404, 409, or 422, or retries a validation failure
REL_38972
A client-library retry policy and an application-level retry wrapping the same outbound dependency
REL_42194
A remote dependency guarded by timeouts and retries alone, with no breaker and no defined open-circuit path

FAQ

What is retry, timeout and circuit breaker discipline?

+

It is the practice of making every outbound call state, explicitly, how long it will wait, whether it may be retried, how many times, and what happens when the dependency is clearly down. The three controls work as one: a timeout bounds a single attempt, a retry policy bounds total attempts, and a circuit breaker stops attempting at all once failure is established.

Applied separately they fight each other. Retries without timeouts queue forever, timeouts without breakers hammer a failing service, and breakers without either never open in time to matter.

What is exponential backoff with jitter?

+

Exponential backoff increases the wait between retries geometrically (100ms, 200ms, 400ms) so a struggling dependency gets progressively more room to recover. Jitter adds randomness to each interval so that clients which failed at the same moment do not retry at the same moment.

Jitter is the part teams skip and the part that matters most. Without it, every client that failed during a five second blip retries in lockstep, producing a synchronized traffic spike precisely when the dependency is least able to absorb one. Full jitter, where the wait is a random value between zero and the backoff ceiling, is the standard recommendation.

What is a retry storm?

+

A retry storm is a self-amplifying traffic surge caused by retries stacking across layers. A brief slowdown causes each client to retry, and if three layers of the call chain each retry three times, one user request becomes 27 requests to the service at the bottom. The dependency that was briefly slow is now genuinely overloaded, which triggers more retries.

The structural fix is retrying at one layer only, usually the one closest to the failure, and giving every retry policy a total attempt budget rather than a per-call multiplier.

Should every failed call be retried?

+

No, and retrying by default is how a partial failure becomes a data integrity problem. Retry only what is both transient and idempotent: a connection timeout, a 503, a 429 with a Retry-After header, on an operation that produces the same result if it runs twice. A non-idempotent POST that timed out may have succeeded, and retrying it charges the customer again.

The genuinely hard case is the timeout on a write. You cannot tell from the client whether the operation completed, and both choices are wrong some of the time. That ambiguity is what idempotency keys exist to resolve, and it is the reason the decision belongs in the design rather than in a default client configuration.

How do I tell if our retries are making outages worse?

+

During your next dependency incident, compare inbound request volume at the failing service against normal. If it went up while user traffic stayed flat, retries are amplifying rather than absorbing. Then count the layers: for one user request, how many independent retry policies sit between the edge and the dependency?

Two static checks worth running now rather than during an incident. Find every outbound HTTP or gRPC client with no explicit timeout set, since language defaults are frequently infinite. And find every retry configuration that is not paired with backoff and jitter.

Can't the service mesh or the HTTP client library handle retries?

+

A mesh like Istio or Linkerd applies a uniform retry policy without knowing which operations are idempotent, so it will retry the payment POST alongside the profile GET. Client libraries default to something reasonable for a generic case and are frequently overridden per call site by whoever wrote that call. Neither has visibility into how many other layers are retrying the same request.

What is actually needed is a rule that every outbound call declares a timeout, that retries appear at one layer only, and that non-idempotent operations are excluded, checked on every pull request that adds a client call and scanned across repositories for the call sites that predate the rule.

Get the Guideline Package

You'll get this as a guideline MD file - hand it straight to your own coding agent or CI setup. Or skip the DIY step: give these same guidelines real enforcement superpowers, with PR checks and scans live on your repos, through Pandorian.

From Principles to Enforcement

Every rule in this guide is easy to agree with in a design review and easy to violate six sprints later, one locally-correct decision at a time. Pandorian turns boundary discipline like this into CI guardrails - scoped to the repos, teams or services you choose.