ASP.NET Core API Resilience: Timeouts, Retries, and Circuit Breakers
A production-focused guide to timeout budgets, safe retries, circuit breakers, cancellation, observability, and common .NET interview traps.
Production resilience is not about retrying every failure. It is about bounding work, protecting dependencies, and making failure modes predictable. This note gives an interview-ready mental model and a practical .NET implementation checklist.
Start with a timeout budget
Every outbound call should have a deadline shorter than the caller's overall request budget. If an HTTP request has a 2-second service-level objective, do not give a dependency the full 2 seconds: reserve time for serialization, other dependencies, and returning a useful error.
A configured timeout is not proof that all work has stopped. Polly timeouts request cooperative cancellation; callbacks must honor the pipeline token, and an uncooperative callback can delay completion beyond the configured timeout. Propagate cancellation and measure actual resource release. Cancellation does not guarantee that a remote server stopped or rolled back a side effect. See Polly timeout behavior.
Retry only transient, safe operations
Good retry candidates include temporary connection failures, HTTP 408, HTTP 429 when the server supplies retry guidance, and selected 5xx responses. Do not automatically retry validation failures, authentication failures, or deterministic business conflicts.
Before retrying a write, verify idempotency. A payment, email, or inventory decrement may be repeated if the first response is lost. Use an idempotency key or an operation identifier stored with the result.
Use exponential backoff with jitter so many instances do not retry together. Keep the attempt count small and ensure the complete retry sequence fits inside the caller's timeout budget.
Circuit breakers protect the dependency
A circuit breaker stops sending calls when failures show that a dependency is unhealthy. In the open state, calls fail quickly. After a cooling period, a limited probe is allowed in the half-open state. A successful probe can close the circuit; another failure opens it again.
The breaker is not a replacement for timeouts. Without a timeout, slow calls can exhaust the connection pool before the breaker observes enough failures.
.NET 10 resilience pipeline
Use the Microsoft.Extensions.Http.Resilience package for standard HTTP resilience policies. Keep the policy close to the typed or named HttpClient so its ownership is obvious. This Program.cs registration fragment assumes an ASP.NET Core builder, the package namespace Microsoft.Extensions.Http.Resilience, and your InventoryClient type. The three-second budget is illustrative; lower it to fit the caller’s remaining deadline.
builder.Services
.AddHttpClient<InventoryClient>(client =>
{
client.BaseAddress = new Uri(builder.Configuration["Inventory:BaseUrl"]!);
})
.AddStandardResilienceHandler(options =>
{
options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(1);
options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(3);
options.Retry.MaxRetryAttempts = 2;
options.Retry.DisableForUnsafeHttpMethods();
options.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(30);
options.CircuitBreaker.FailureRatio = 0.5;
options.CircuitBreaker.MinimumThroughput = 20;
});The default standard handler can retry every HTTP method. The explicit guard above disables retries for unsafe methods; enable write retries only under a deliberate idempotency contract. See Microsoft HTTP resilience guidance.
Read this configuration as limits, not a schedule. Two retries allow at most three attempts; they do not promise three completed attempts. The standard order is concurrency limiter, total timeout, retry, circuit breaker, then attempt timeout. Backoff spends the total timeout. If you enable limiter queueing, that outer wait is outside this pipeline total timeout, so the caller deadline must also cover admission. The snippet leaves retry delay and jitter at package defaults. Fit those settings to the operation; do not assume that three one-second attempts plus backoff fit in three seconds.
Pass cancellation through every layer. This method belongs inside the application’s InventoryClient class with an injected HttpClient, an InventoryItem model, and the System.Net and System.Net.Http.Json namespaces:
public async Task<InventoryItem?> GetAsync(
string sku,
CancellationToken cancellationToken)
{
using var response = await httpClient.GetAsync(
$"inventory/{Uri.EscapeDataString(sku)}",
cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
return null;
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<InventoryItem>(
cancellationToken: cancellationToken);
}Common interview traps
- Stacking retries: Application, proxy, and SDK retries multiply. If three nested layers each allow two retries, one original call can produce up to 3 × 3 × 3 = 27 attempts at the deepest layer. Define one owner for retry behavior.
- Retrying non-idempotent writes: A timeout means the response is unknown, not that the server did nothing.
- Ignoring Retry-After: Respect server-directed backoff for throttling responses. If that delay leaves no useful attempt time inside the remaining budget, stop instead of retrying early or extending the deadline.
- Using one global circuit: Isolate breakers by dependency and, where appropriate, operation. An unhealthy optional endpoint should not block an unrelated critical call.
- Hiding failures: Emit structured logs and metrics for attempts, timeouts, breaker state changes, final outcome, and dependency latency. Avoid logging secrets or complete payloads.
Production checklist
- Define the end-to-end latency budget before individual timeouts.
- Propagate CancellationToken from the HTTP request.
- Reuse HttpClient through IHttpClientFactory.
- Retry only classified transient failures.
- Require idempotency for retried writes.
- Add jitter and cap the total retry duration.
- Use a circuit breaker per dependency boundary.
- Bound admission and queueing too; a closed breaker is not a capacity limit.
- Test timeout, throttling, slow-success, and recovery paths.
- Monitor final failures separately from recovered retries.
- Return a controlled fallback only when stale or partial data is safe.
Check your reasoning
Question: With the shown settings, may you assume that every eligible request gets three full one-second attempts?
Answer: No. Three full attempts already consume three seconds before any backoff or overhead. MaxRetryAttempts = 2 is a ceiling. A fast failure may leave room for another attempt; elapsed time, the remaining deadline, and retry eligibility decide whether it is useful. These are illustrative settings, not a measured latency guarantee.
Interview answer in one minute
I start with an end-to-end deadline and propagate cancellation. Each dependency gets a smaller timeout. I retry only transient failures, with jitter and a small attempt count, and only when the operation is idempotent. A circuit breaker fails fast during sustained dependency failure. I stop starting retries when the deadline expires and test cancellation, final outcomes, and resource release separately. A timeout setting alone does not prove that child operations have ended.