ASP.NET Core HttpClient Resilience: Reliable Outbound API Calls in Production
Calling a payment provider, identity service, or internal API turns your endpoint into a dependency chain. A transient failure should not quietly become a thundering herd of retries, nor should a slow upstream hold every request thread until capacity disappears. This guide shows a practical .NET 10 baseline for outbound HTTP calls.
Start with a named client, not a client per request
HttpClient owns a connection pool. Creating and disposing one for every request discards that pool and can create connection pressure. Microsoft recommends either long-lived clients with a configured connection lifetime or clients created through IHttpClientFactory; the factory manages handler reuse and lets each dependency have explicit configuration. See the official HttpClient guidelines and the IHttpClientFactory documentation.
using Microsoft.Extensions.Http.Resilience;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient("catalog", client =>
{
client.BaseAddress = new Uri("https://catalog.internal/");
client.Timeout = Timeout.InfiniteTimeSpan; // Let the resilience pipeline own time limits.
})
.AddStandardResilienceHandler(options =>
{
options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(8);
options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(2);
options.Retry.MaxRetryAttempts = 2;
});
var app = builder.Build();
app.MapGet("/products/{id}", async (string id, IHttpClientFactory clients, CancellationToken ct) =>
{
var client = clients.CreateClient("catalog");
using var response = await client.GetAsync($"products/{id}", ct);
return response.IsSuccessStatusCode
? Results.Content(await response.Content.ReadAsStringAsync(ct), "application/json")
: Results.StatusCode(StatusCodes.Status502BadGateway);
});
app.Run();
public partial class Program { }
Make the failure budget explicit
The standard resilience handler composes a rate limiter, total timeout, retry, circuit breaker, and attempt timeout. Those defaults are a starting point, not a service-level objective. Choose a total budget that leaves time for your own response and downstream work; an eight-second aggregate budget with two-second attempts is deliberately different from allowing each retry to consume the whole request deadline. The resilience guidance documents the pipeline and the supported configuration surface.
Retry only operations whose semantics allow it. A safe read is usually a candidate. A POST that charges a card is not automatically safe merely because the network broke: use an idempotency key and an upstream contract that honors it before retrying. Also propagate the caller cancellation token, so abandoned browser requests do not keep consuming downstream capacity.
Use status codes and exceptions as separate signals
An HTTP response can be perfectly delivered yet unsuccessful for your business operation. Treat a 429, 503, and a connection failure differently in telemetry, and avoid collapsing all of them into a generic “error.” HTTP’s request/response semantics and status-code classes are defined in RFC 9110.
Set a response policy at the boundary: map a known upstream absence to the behavior your API promises; map an unavailable dependency to 502 or 503 as appropriate; and never return raw upstream error bodies that may disclose internals. This pairs well with the error-envelope approach in consistent Problem Details responses.
Observe before you tune
Log the logical client name, dependency host, status code, elapsed time, and retry outcome. Keep secrets, authorization headers, and full request bodies out of logs. A timeout is not automatically a network outage: it may expose an overly small budget, a slow database query, or a pool that is already saturated. For database-backed APIs, review Npgsql connection-pooling practices alongside outbound-call telemetry.
Production checklist
- Give each dependency a named or typed client, base address, and owner.
- Set a total deadline, per-attempt deadline, retry limit, and circuit-breaker policy deliberately.
- Retry only idempotent or explicitly idempotency-keyed operations.
- Pass cancellation tokens through every async boundary.
- Measure dependency latency and failure classes; alert on sustained breaker openings, not single failures.
- Exercise timeouts, 429s, 5xx responses, malformed payloads, and DNS/endpoint changes in a non-production environment.
Conclusion
Resilience is controlled failure, not “retry everything.” A configured client, a bounded budget, safe retry rules, and useful telemetry keep one unhealthy dependency from becoming an outage in your ASP.NET Core service.
1 Comments