Introduction
The Circuit Breaker article explained when to stop sending requests to a failing dependency. Before a breaker opens, each individual operation still needs a bounded execution policy.
Retry, Timeout, and Backoff answer three different questions:
- Timeout: How long may this work consume the caller’s budget?
- Retry: Which failed outcomes are safe and useful to attempt again?
- Backoff: When should the next attempt occur so recovery traffic does not become another outage?
They must be designed together. A retry count without a total deadline can violate the API’s latency objective. A timeout without cancellation cooperation can leave abandoned work running. Exponential backoff without jitter can synchronize thousands of clients.
The Production Problem
An order API has a 2-second latency objective and calls inventory. The client uses a 1-second timeout plus three retries with delays of 200, 400, and 800 milliseconds.
The apparent configuration cannot fit:
4 attempts × 1 second + 1.4 seconds delay = 5.4 seconds
That excludes DNS, connection setup, serialization, scheduling, and the work already spent in the caller. When inventory slows, requests pile up for longer than clients are willing to wait. Upstream callers time out and retry too, multiplying load.
If the operation is POST /reservations without an idempotency key, a timed-out attempt may have committed. Retrying can create a second reservation.
The production problem is not “How do we retry?” It is “How do we spend a finite deadline without repeating unsafe work or delaying recovery?”
Core Concepts
A timeout is a budget, not proof of failure
Caller timeout means the caller stopped waiting. It does not prove the server stopped or rolled back.
Distinguish:
- attempt timeout: bounds one physical call;
- total timeout: bounds all attempts and delays for one logical operation;
- upstream deadline: bounds the complete request across every dependency;
- business deadline: a durable time limit for a long-running workflow.
Do not replace one with another.
Retry requires three conditions
Retry only when:
- the failure is plausibly transient;
- another attempt can fit inside the remaining budget;
- repeating the operation is safe.
Transport resets, some 408, 429, and 5xx responses can be transient. Validation errors, authentication failures, and business rejection usually are not.
HTTP method alone is not enough. A POST with a server-enforced idempotency key may be retryable. A nominally safe but expensive query may amplify overload.
Backoff reduces pressure
Immediate retry competes with the failing attempt’s recovery. Exponential backoff increases the delay between attempts. Jitter randomizes it so concurrent clients do not retry in synchronized waves.
Honor a valid server-provided Retry-After when it fits the remaining deadline and policy. Bound every delay; a retry scheduled beyond the caller’s useful window should not run.
Attempts consume shared capacity
One logical request can create several physical calls. Track both rates. Capacity planning based only on incoming requests hides retry amplification.
A concurrency limiter bounds in-flight work. Circuit Breaker stops correlated failures. Retry cannot replace either.
How It Works
- The caller establishes an end-to-end deadline.
- A concurrency limiter admits bounded work.
- A total timeout wraps the logical operation.
- Retry evaluates each result against failure classification, idempotency, remaining budget, and attempt count.
- Backoff with jitter delays eligible retries.
- An attempt timeout bounds each physical call.
- Circuit Breaker observes the configured failure scope and eventually fails fast during correlated faults.
Pipeline order changes what each strategy observes. With Retry outside attempt Timeout, every timed-out attempt can be retried. With total Timeout outside Retry, the complete operation remains bounded.
Architecture Diagram
.NET Implementation
Start with Microsoft.Extensions.Http.Resilience for HttpClient. The maintained standard handler already combines a rate limiter, total timeout, retry, circuit breaker, and attempt timeout.
using Microsoft.Extensions.Http.Resilience;
builder.Services
.AddHttpClient<InventoryClient>(client =>
{
client.BaseAddress = new Uri(
builder.Configuration["Inventory:BaseUrl"]
?? throw new InvalidOperationException(
"Inventory:BaseUrl is required."));
})
.AddStandardResilienceHandler(options =>
{
options.TotalRequestTimeout.Timeout =
TimeSpan.FromSeconds(2);
options.AttemptTimeout.Timeout =
TimeSpan.FromMilliseconds(600);
options.Retry.MaxRetryAttempts = 2;
options.Retry.Delay =
TimeSpan.FromMilliseconds(100);
options.Retry.BackoffType =
DelayBackoffType.Exponential;
options.Retry.UseJitter = true;
options.Retry.DisableForUnsafeHttpMethods();
options.CircuitBreaker.SamplingDuration =
TimeSpan.FromSeconds(20);
options.CircuitBreaker.MinimumThroughput = 20;
options.CircuitBreaker.FailureRatio = 0.5;
options.CircuitBreaker.BreakDuration =
TimeSpan.FromSeconds(30);
});
These numbers are illustrative, not defaults to copy. Derive them from the upstream deadline, observed dependency latency, connection behavior, and retry safety.
For custom outcome semantics:
builder.Services
.AddHttpClient<InventoryStatusClient>()
.AddResilienceHandler("inventory-status", static pipeline =>
{
var transient = new PredicateBuilder<HttpResponseMessage>()
.Handle<HttpRequestException>()
.Handle<TimeoutRejectedException>()
.HandleResult(response =>
response.StatusCode is
HttpStatusCode.RequestTimeout or
HttpStatusCode.TooManyRequests
|| (int)response.StatusCode >= 500);
pipeline.AddTimeout(TimeSpan.FromSeconds(2));
pipeline.AddRetry(new HttpRetryStrategyOptions
{
ShouldHandle = transient,
MaxRetryAttempts = 2,
Delay = TimeSpan.FromMilliseconds(100),
BackoffType = DelayBackoffType.Exponential,
UseJitter = true
});
pipeline.AddCircuitBreaker(
new HttpCircuitBreakerStrategyOptions
{
ShouldHandle = transient,
SamplingDuration = TimeSpan.FromSeconds(20),
MinimumThroughput = 20,
FailureRatio = 0.5,
BreakDuration = TimeSpan.FromSeconds(30)
});
pipeline.AddTimeout(TimeSpan.FromMilliseconds(600));
});
Polly timeout throws TimeoutRejectedException, not TimeoutException. Caller cancellation should normally remain distinct and should not count as dependency failure.
Pass the provided CancellationToken through every asynchronous operation. Polly can stop awaiting an uncooperative operation, but that operation may continue consuming resources if the underlying API ignores cancellation.
For unsafe commands, prefer a durable idempotency key:
request.Headers.Add(
"Idempotency-Key",
command.PaymentAttemptId.ToString("N"));
The receiving service must enforce the key and return the original compatible outcome. Adding the header without server-side persistence provides no guarantee.
Common Mistakes
Retrying every exception
Programming bugs, invalid configuration, authentication failure, and caller cancellation do not become healthy through repetition.
Multiplying retry layers
HTTP client, SDK, service mesh, gateway, job runner, and upstream caller may all retry. Two attempts at three layers can produce eight downstream calls. Assign retry ownership and inspect library defaults.
Exceeding the total deadline
Attempt timeout multiplied by attempts plus backoff must fit the remaining useful time. Leave budget for caller processing and response delivery.
Retrying unsafe operations without idempotency
A timeout is ambiguous. The server may have committed. Use a business idempotency key or do not retry automatically.
Deterministic backoff
Identical exponential schedules create retry waves. Enable jitter and load-test the resulting distribution.
Using timeout as cancellation proof
The caller giving up does not prove downstream work stopped. Design cancellation cooperation and server-side deadlines where supported.
Logging every attempt as an incident
Retries are expected at low rates. Record attempt events at an appropriate level and alert on exhausted logical operations, amplification, and sustained failure.
Best Practices
- Begin with the caller’s total deadline.
- Classify outcomes by dependency contract.
- Retry only safe and plausibly transient failures.
- Keep attempts few; use exponential backoff with jitter.
- Honor bounded
Retry-Afterguidance. - Combine retries with concurrency limits and Circuit Breaker.
- Disable duplicated retries in adjacent layers.
- Test behavior with the real client library and package version.
Observability
Separate logical-operation metrics from attempt metrics:
- logical requests, successes, failures, and duration;
- physical attempts and attempts per operation;
- retry reason and delay;
- attempt and total timeouts;
- exhausted retries;
- cancellations by caller;
- concurrency rejections;
- circuit state transitions.
Keep endpoint templates and dependency names low-cardinality. Do not label metrics with URLs containing IDs.
Traces should show each physical dependency attempt beneath one logical operation. Record retry count and timeout classification without marking a recovered retry as an overall request failure.
Verification
Use a controllable dependency to return a sequence such as 503, timeout, then 200. Verify exact attempts, delays within a jitter range, and total duration below the deadline.
Then test:
401,422, and caller cancellation produce no retry;- an unsafe command produces no retry without an approved idempotency contract;
- thousands of clients do not retry at identical instants;
- an uncooperative handler is detected as continuing after caller timeout;
- retry amplification remains within downstream capacity during load tests.
Decision Matrix
| Failure or operation | Retry? | Required control |
|---|---|---|
| Connection reset during deployment | Usually | Small bounded retry with jitter |
429 with Retry-After | Context-dependent | Honor only within remaining deadline |
503 from an overloaded dependency | Limited | Backoff, concurrency limit, Circuit Breaker |
401 or 403 | No | Repair identity or authorization |
Business 409 or 422 | Usually no | Return or handle domain outcome |
| Caller cancellation | No | Stop work and preserve cancellation semantics |
| Idempotent read | Maybe | Check cost, staleness, and deadline |
| Command with enforced idempotency key | Maybe | Reuse the same key and compatible response |
| Command without idempotency support | Usually no | Surface ambiguity or reconcile |
| Long-running business workflow | Not with HTTP retries | Use durable workflow state and deadlines |
Relationship to Other Patterns
Circuit Breaker stops repeated correlated failures; Retry handles a small number of isolated transient failures.
Transactional Outbox persists publication attempts across restarts. Its durable schedule is different from an in-memory retry delay. Idempotent Consumer makes repeated delivery safe.
A Saga treats business rejection and durable deadlines as state-machine transitions, not generic retry outcomes. A Dead Letter Queue should receive classified terminal messages only after the retry policy is exhausted or the failure is known permanent.
Decision checklist
- What is the end-to-end deadline?
- How much time may one attempt consume?
- Which exact outcomes are transient?
- Is repetition safe under an ambiguous timeout?
- How many retry layers exist?
- Does backoff include jitter and respect
Retry-After? - Are concurrency and Circuit Breaker limits present?
- Can operators distinguish attempts from logical operations?
- Do fault tests prove timing, cancellation, and amplification behavior?
Key takeaways
- Timeout means the caller stopped waiting, not that the operation failed atomically.
- Retry requires transience, remaining budget, and idempotency.
- Backoff without jitter can synchronize recovery traffic.
- Multiple retry layers multiply load.
- Observe physical attempts and logical outcomes separately.