Reliability guide
Dependency Resilience Patterns: Timeouts, Retries, Backpressure, and Fallbacks
A dependency call is part of your service's failure domain. Bound the time, work, and retries it can consume before an upstream incident becomes your outage.
On this page
Most production failures are not a single process crash. A database slows down, DNS resolution stalls, a third-party API returns errors, or a region loses capacity. If callers wait indefinitely, retry without limits, and scale into the same constrained dependency, a partial fault becomes a cascading outage.
Dependency resilience patterns are the explicit decisions that prevent that cascade: timeouts, cancellation, retry policy, concurrency limits, circuit breaking, queues, fallbacks, and telemetry. These controls are application behavior, not a sidecar or Kubernetes setting enabled blindly. They matter most at the scale a managed edge and orchestration layer runs at — many origins, providers, and regions, where one under-specified policy in one service quietly becomes everyone's incident. A platform that watches Performance, Security, and Visibility across that surface, such as Optimi's MYO, only reports the truth if the services underneath it fail in bounded, honest ways.
The core dependency resilience patterns
There are six patterns that recur in every dependency resilience strategy, and they compose rather than substitute for each other. Timeouts and deadlines bound how long a call is allowed to run. Retries with a budget decide whether and how often a failed idempotent call is repeated. Circuit breakers stop calling a dependency that is already failing. Bulkheads isolate the connection pools, thread pools, or queues of one dependency so its failure cannot starve callers of an unrelated one. Backpressure and load shedding decide what happens once a limit is reached. Fallbacks decide what the caller tells its own user when the dependency cannot be reached in time.
A seventh pattern, request hedging, is narrower: for read-only, idempotent calls where tail latency matters more than raw request volume, a client fires a second request to a different replica once the first passes its 95th-percentile expected latency, then takes whichever answer returns first and cancels the loser. Hedging trades a small, capped amount of duplicate load for a large cut in p99 latency; it is unsafe for anything that writes.
Start with a dependency map and latency budget
For every request path, map each synchronous and asynchronous dependency: DNS, edge or ingress, service calls, database, cache, queue, identity provider, payment system, and external API. Record ownership, protocol, normal and tail latency, error behavior, quota, connection limit, data criticality, and fallback option.
Then set an end-to-end deadline from the user-facing entry point. Divide it across the work that must happen. A 1,000 ms user budget cannot contain three serial 500 ms default client timeouts. Leave time for queuing, serialization, and a response; do not allocate the entire budget to downstream calls.
Propagate deadlines and cancellation through service calls. When the client has left or the upstream deadline expires, continuing expensive work consumes capacity that cannot improve the response. Use monotonic clocks for elapsed-time calculations and distinguish connection, TLS handshake, first-byte, idle, and overall request timeouts when the client library supports them.
Retry only when it is safe and affordable
Retries can improve transient failure recovery, but each retry adds load to a dependency that may already be overloaded. Retry only operations that are idempotent or protected by an idempotency key. Never retry a payment, message publication, or mutation simply because the client did not receive a response; first know whether the server may have completed it.
Use a small retry count, exponential backoff, jitter, and a maximum elapsed time smaller than the caller's remaining deadline. Honor Retry-After, and never retry a plain client error: a 400 or 422 will fail identically on attempt two. Establish a retry budget so a failing upstream cannot multiply your request volume indefinitely. Google's SRE guidance for handling overload describes a workable shape: cap retries per request (three attempts before the failure surfaces to the caller), and separately cap the ratio of retried to total requests per client, suppressing further retries once that ratio crosses a low threshold such as ten percent. The two limits catch different failure shapes — one bounds a single caller's patience, the other bounds how far a systemic fault can multiply fleet-wide traffic.
Avoid retrying at multiple layers. If the edge, gateway, service mesh, SDK, and application each retry three times, the multiplier compounds across all five — one original request can become hundreds of dependency calls, not the handful any single layer's team assumed. Assign retry ownership to one layer per operation and observe attempts separately from user requests; the OpenTelemetry HTTP semantic conventions define http.request.resend_count for exactly this, so a resent call is distinguishable from the original in traces.
Reserve retries for the failure case and hedging (above) for the latency case; combining both against the same dependency without one shared budget can double-count load during an incident.
A timeout is a capacity control
An unbounded wait holds a worker, connection, memory, and often a request slot. A sensible deadline protects the latency budget and leaves capacity for requests that can still succeed. Tune it from measured tail latency and product requirements, not a copied default.
Limit concurrency and apply backpressure
Every dependency client needs a maximum number of concurrent calls and a bounded queue. Once the limit is reached, choose a deliberate response: queue work with a deadline, return a cached result, shed low-priority traffic, or fail fast with a retryable error. An unbounded internal queue converts a brief upstream slowdown into memory pressure and long tail latency.
Isolate that limit per dependency, not just per service: the bulkhead pattern. Give the payment client its own connection pool and bounded queue, separate from the one used for the recommendations API or the inventory cache. Without that separation, one slow dependency exhausts a shared pool and starves callers of every other dependency the service also needs — one flooded compartment sinking a ship that bulkheads would have kept afloat.
Size connection pools for the dependency and fleet, not one container. Little's Law gives a starting number: needed concurrency is roughly target throughput multiplied by the call's expected duration. A dependency sustaining 800 requests per second at a 550 ms p99 needs on the order of 440 concurrent in-flight calls fleet-wide, plus headroom — not 440 per replica. Per-pod pools sized without dividing by replica count can overwhelm a database or NAT gateway long before any single pod looks unhealthy. Track active and idle connections, acquisition wait time, rejected work, and replica count. A horizontal autoscaler reacting to CPU or queue depth can add pressure unless coordinated with dependency capacity: adding replicas without shrinking each one's pool share just multiplies the fleet-wide concurrency the dependency sees.
Use asynchronous queues for work that does not need to finish in the user response. Set maximum age, retry and dead-letter policy, consumer concurrency, and idempotent processing. Queues provide buffering, not infinite capacity: a rising age is a user-impact signal and needs a load-shedding or recovery decision.
Circuit breakers and retries need a shared, honest fallback
Circuit breakers and retries solve different halves of the same problem: a retry assumes the next attempt might succeed, while a breaker assumes it will not and stops trying for a while. A circuit breaker opens when failure or latency crosses a defined threshold, preventing more calls while the dependency recovers. It should have a bounded open interval, a half-open test that admits only a few trial requests, and a close condition based on those trials succeeding rather than a fixed timer alone. Scope breakers by dependency and operation; one slow optional recommendation API should not block checkout. Keep the breaker and retry policy for one dependency in the same code path — a retry loop that keeps attempting after the breaker has opened defeats the breaker's purpose.
Fallback behavior must preserve correctness. Good fallbacks include serving a known-safe cached catalog, accepting a request into a durable queue, or hiding a nonessential widget. Bad fallbacks include serving stale authorization data, silently dropping a financial action, or returning a success response for work that was never recorded.
For state-changing work, use patterns that make recovery explicit: idempotency keys, transactional outbox records, sagas with compensating actions, and reconciliation jobs. A circuit breaker improves availability only when the product can make a truthful response while the dependency is unavailable.
Retries are allowed only while the circuit is closed and the shared budget remains. An open breaker protects capacity by making the fallback path explicit.
Design for cloud-native and multi-region realities
Kubernetes restarts failed containers, but it does not know whether retrying an external API is safe or whether a database has reached its connection limit. Never check a downstream dependency from a liveness probe: a shared dependency fault becomes a mass restart of every replica that depends on it, the opposite of the isolation a breaker or bulkhead exists to buy. A readiness probe may reflect a shared dependency only if its timeout exceeds that dependency's own worst-case response time and its failure threshold tolerates a brief spike — otherwise a slow-but-recovering dependency pulls every replica out of rotation at once, traffic backs up onto the few pods still marked ready, and the resulting pressure pushes the dependency further into the slowdown it caused.
Deploy replicas across failure domains when the service requires it, and test the behavior when a zone, DNS resolver, credential issuer, or regional dependency fails. Multi-region routing adds its own concerns: replication lag, write ownership, data residency, health-check quality, and cache invalidation. Sending a request to a healthy region is not useful if that region cannot safely serve the required data.
At the edge, set origin connect and response timeouts, retry rules, and failover criteria to match application semantics. Restrict automatic retries to safe methods or explicitly idempotent requests. Cacheable responses can be served closer to users during an origin impairment, while personalized and mutation paths need an honest degraded response or controlled queue. Monitor latency by region and route, not just global averages.
Multi-provider consistency is a resilience control too
When a request path crosses several edge and infrastructure providers — a DNS resolver, a CDN, a WAF, an origin cloud — each one's default timeout and failover behavior is independently reasonable and jointly uncoordinated. Orchestrating those providers to one application-aware policy, and watching the result through one observability layer such as MYO, closes the gap between "every provider is healthy" and "the request actually completed correctly" — without asking any of the vetted, best-of-breed providers to change how they work.
Instrument the whole call chain
Use OpenTelemetry to create spans for dependency calls and propagate trace context across HTTP, gRPC, and messaging boundaries. Record operation name, target system, status, duration, retry count, timeout reason, and safe route attributes. Do not put access tokens, full query values, or personal data into span attributes.
Combine traces with metrics: dependency request rate, availability, p50/p95/p99 duration, timeout count, retry count, circuit state, queue age, pool wait time, and saturation. Correlate these with deploys, configuration changes, and edge routing events. The goal is to tell whether users are waiting on application code, a shared dependency, or the network path.
Exercise failure before it exercises you
Run controlled tests in a safe environment. Add latency, return 429 and 503 responses, blackhole a route, exhaust a connection pool, delay a queue consumer, and rotate a dependency credential. Confirm that deadlines bound the request, retries do not surge, fallbacks are truthful, alerts fire on user impact, and recovery does not require manual repair of duplicate work.
Document the decision for each critical dependency: owner, SLO, timeout, retry rule, concurrency limit, fallback, data-loss risk, and escalation path. Review it whenever the dependency, traffic profile, or regional architecture changes.
Apply it: Acme Shop checkout to payments
Overview
Outcome and prerequisites
Outcome: Acme Shop checkout returns a truthful result within its 900 ms budget when its payment provider slows or fails. Prerequisites: a non-production payment stub, request tracing, and a safe way to inject latency and 503 responses.
checkout-api has 900 ms to respond. It reserves 120 ms for its own work and response, then permits one bounded payment attempt with a 550 ms deadline. It retries only a safe, idempotency-keyed authorization once when at least 180 ms remains; otherwise it reports payment confirmation as pending rather than claiming success.
- 900 ms checkout deadline
- Bounded concurrency gate
- 550 ms payment attempt
- Circuit breaker
- Truthful pending or success response
Figure 1. The checkout deadline bounds queueing and the payment call; the breaker prevents a slow provider from consuming every checkout worker.
Budget calculation and deadline-aware call
900 ms - 120 ms application/respond - 50 ms bounded queue = 730 ms remains for payment. Acme Shop allocates 550 ms to the first attempt and permits a second 140 ms attempt only when the stable idempotency key is present and more than 180 ms remains. The unused 40 ms is deliberate guard time.
const remaining = request.deadline.remainingMs()
if (remaining < 180 || !paymentBreaker.allow()) return pendingCheckout()
const timeoutMs = Math.min(550, remaining - 120)
const result = await payments.authorize({ idempotencyKey, timeoutMs })
if (result.retryable && request.deadline.remainingMs() > 180) {
return payments.authorize({ idempotencyKey, timeoutMs: 140 })
}
return result
trace=acme-9f2 payment attempt=1 timeout_ms=550 outcome=deadline_exceeded
trace=acme-9f2 retry=false reason=remaining_budget_170ms
trace=acme-9f2 checkout outcome=pending_payment http_status=202Concurrency gate and bulkhead sizing
Acme Shop's checkout fleet peaks at 300 requests per second during flash sales. Little's Law on the payment call's 550 ms worst-case deadline gives a fleet-wide concurrency need of roughly 300 × 0.55 s ≈ 165 in-flight calls. Acme Shop caps the payment bulkhead pool at 220 fleet-wide — 30 percent of margin — kept separate from the pool used for shipping-rate and recommendations calls, so a slow payment provider cannot also stall shipping quotes. Across 22 checkout replicas that is roughly 10 concurrent payment calls per pod, divided down from the fleet total rather than set per pod and multiplied up. Once a replica's share is exhausted, new requests fail fast into the pending path from the diagram above instead of opening connections the provider was never sized for:
const gate = payments.bulkheadPool // bounded, dependency-specific — not shared
if (!gate.tryAcquire()) return pendingCheckout()
try {
return await payments.authorize({ idempotencyKey, timeoutMs })
} finally {
gate.release()
}
Fault runbook, validation, and rollback
In the sandbox, configure the payment stub to delay 700 ms and return 503 for a small test cohort. Validate that checkout completes or reports pending before 900 ms, payment concurrency stays capped, retries do not exceed the budget, and no duplicate authorization appears for the idempotency key. If the policy change causes a regression, disable the candidate retry or breaker configuration, restore the last reviewed policy, and reconcile pending orders from durable records rather than replaying every request.
| Symptom | Likely cause | Safe check | Recovery |
|---|---|---|---|
| p99 rises above 900 ms | Client deadline is absent or too large. | Inject 700 ms provider latency. | Enforce propagated deadline and cancel remaining work. |
| Provider QPS spikes during 503s | Retries occur at multiple layers. | Compare attempts to original requests. | Disable duplicate retry owners and apply a retry budget. |
| Checkout says success but no payment exists | Fallback is not truthful. | Force one provider timeout with a test order. | Return pending and reconcile the durable order record. |
| All checkout workers wait on payments | Concurrency queue is unbounded, or the payment bulkhead shares a pool with another dependency. | Hold the sandbox provider response. | Cap calls per dependency, shed low-priority work, and open the breaker. |
| Duplicate authorizations appear for one order | The retry fired after the first attempt had actually succeeded upstream. | Compare the idempotency key and provider receipt timestamps for the order. | Confirm the idempotency key is stable across attempts and reconcile from the provider's record of truth. |
| Checkout pods restart during a provider slowdown | The liveness probe checks the payment dependency directly. | Compare probe timeout and failureThreshold to the dependency's own worst-case latency. | Remove dependency checks from liveness; raise the readiness timeout and failureThreshold instead. |
Related guides
- Distributed Systems Latency Budgets
- Queue-Based Load Leveling
- Stateless Services on Kubernetes
- Safe Container Releases
Authoritative references
- The Twelve-Factor App: Backing services
- Google SRE Book: Handling overload
- Google SRE Book: Addressing cascading failures
- AWS Builders' Library: Timeouts, retries, and backoff with jitter
- OpenTelemetry: Semantic conventions for HTTP spans
- Kubernetes: Liveness, Readiness, and Startup Probes
Turn dependency resilience patterns into fleet-wide policy
Optimi's Performance, Security, and Visibility orchestration — observed end to end through MYO — helps keep timeout, retry, circuit breaker, and backpressure policy consistent across every provider on the path, not just inside one service.
Discuss resilient delivery