Error handling, health checks and resilience
One exception handler, health endpoints that mean something, and outbound calls with timeouts, retries and a circuit breaker.
Errors and health
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
// IExceptionHandler implementations decide the response shape
app.UseExceptionHandler();
app.UseHsts();
}
app.UseStatusCodePages();
builder.Services.AddExceptionHandler<DomainExceptionHandler>();
builder.Services.AddProblemDetails();
public sealed class DomainExceptionHandler : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext ctx, Exception ex, CancellationToken ct)
{
if (ex is not DomainException domain) return false; // let the next handler try
ctx.Response.StatusCode = StatusCodes.Status422UnprocessableEntity;
ctx.Response.ContentType = "application/problem+json";
await ctx.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = 422,
Title = "The request was rejected",
Detail = domain.Message,
Extensions = { ["code"] = domain.Code, ["traceId"] = ctx.TraceIdentifier },
}, ct);
return true;
}
}
// Health checks: liveness must not depend on downstream services
builder.Services.AddHealthChecks()
.AddCheck("self", () => HealthCheckResult.Healthy(), tags: new[] { "live" })
.AddDbContextCheck<AppDbContext>("database", tags: new[] { "ready" })
.AddUrlGroup(new Uri("https://rates.example.com/health"),
"rates", tags: new[] { "ready" });
app.MapHealthChecks("/health/live",
new HealthCheckOptions { Predicate = r => r.Tags.Contains("live") })
.AllowAnonymous();
app.MapHealthChecks("/health/ready",
new HealthCheckOptions
{
Predicate = r => r.Tags.Contains("ready"),
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse,
})
.AllowAnonymous();- Liveness answers "is this process able to serve" and must never check a dependency. A liveness probe that fails because a database is slow causes an orchestrator to restart healthy processes.
- Readiness answers "should traffic be routed here" and may check dependencies.
- An exception handler that swallows an exception without logging it makes an incident invisible. Log once, at the boundary, with the trace identifier.
- In production, never return a stack trace or an internal message in the response body; keep it in the log and return the trace identifier.
Resilient outbound calls
builder.Services.AddHttpClient<IRatesClient, RatesClient>(c =>
{
c.BaseAddress = new Uri("https://rates.example.com/");
c.Timeout = TimeSpan.FromSeconds(10);
})
.AddStandardResilienceHandler(options =>
{
options.Retry.MaxRetryAttempts = 3;
options.Retry.BackoffType = DelayBackoffType.Exponential;
options.Retry.UseJitter = true;
options.Retry.ShouldHandle = args => ValueTask.FromResult(
args.Outcome.Result?.StatusCode is HttpStatusCode.RequestTimeout
or HttpStatusCode.TooManyRequests
|| (int?)args.Outcome.Result?.StatusCode >= 500);
options.CircuitBreaker.FailureRatio = 0.5;
options.CircuitBreaker.MinimumThroughput = 20;
options.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(30);
options.CircuitBreaker.BreakDuration = TimeSpan.FromSeconds(15);
options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(3);
options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(15);
});
// A typed client, injected wherever it is needed
public sealed class RatesClient : IRatesClient
{
private readonly HttpClient _http;
public RatesClient(HttpClient http) => _http = http;
public async Task<decimal> GetAsync(string pair, CancellationToken ct)
{
var rate = await _http.GetFromJsonAsync<Rate>("rate/" + pair, ct);
return rate?.Value ?? throw new InvalidOperationException("no rate returned");
}
}💡
A retry is only safe when the operation is idempotent, so configure retries per client rather than globally. Retrying a payment because a timeout was reported can charge a customer twice — that needs an idempotency key at the other end, not a retry policy on your side.
FAQ
Why does my circuit breaker never open?
Because the minimum throughput was never reached, or the failure ratio counts successes that arrive faster than the failures. Sample the ratio over a longer duration and check what the breaker considers a failure.
Should health checks be authenticated?
Liveness and readiness endpoints are called by infrastructure that usually cannot authenticate, so they should be anonymous and must not leak internal detail. A richer diagnostics endpoint should be protected.
Related
Model validation and Problem Details responses Testing ASP.NET Core applications
Last refreshed 2026-09-18.