global error handling: what should your api do when it fails?
separate expected outcomes from unexpected failures, return safe problem details and connect the response to a server record with a trace id.
a checkout screen says “something went wrong”. the customer does not know whether the order was saved. the developer does not know which request to inspect. global error handling connects those concerns: it gives the client a predictable response and leaves a useful trail for investigation.
updated:

in this article
01decide whether this is an outcome or a failure
a missing order, insufficient stock and a broken database connection require different decisions. the first two can be expected application outcomes. the connection failure prevents normal execution. turning all three into 500 responses hides whether the client can correct anything.
return 404 for a missing resource or 409 for a conflict with current state when that matches your contract. validation may use 400; authentication and authorization mechanisms produce their own 401 or 403 responses. never classify an unknown exception by searching its message for words such as “not found”.
explicit endpoint results are sufficient for many small applications. introduce a shared result model when application boundaries need it. our central handler deliberately handles unexpected failures as 500.
02where a catch block belongs
copying the same try/catch into every endpoint eventually creates inconsistent responses and duplicate logs. place an exception handler early in the request pipeline to handle exceptions propagated from downstream code.
local catch blocks still make sense when you can recover or translate a failure meaningfully. logging and rethrowing at every layer records the same incident repeatedly. use “throw;” when rethrowing to preserve the existing stack trace.
03a complete request pipeline
with the .net 10 sdk, run “dotnet new web -n ErrorDemo”. replace program.cs with this code and run “dotnet run --no-launch-profile --urls http://localhost:5080” inside the project. no extra package is required. /demo/failure exists solely to demonstrate a failure; remove it from a real application.
order 42 returns 200 and other order ids return an empty 404. the latter does not throw and therefore does not enter the exception handler. the failure endpoint returns an explicit application/problem+json response with a stable application code.
AddProblemDetails registers services. this handler writes its own response to make the contract explicit. if ordinary empty responses such as 404 should use the same format, configure a separate status code pages policy.
using System.Diagnostics;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
var app = builder.Build();
app.UseExceptionHandler();
app.MapGet("/orders/{id:int}", (int id) =>
id == 42
? Results.Ok(new { id, status = "ready" })
: Results.NotFound());
app.MapGet("/demo/failure", Failure);
app.Run();
static IResult Failure() =>
throw new InvalidOperationException("Demo failure");
public sealed class GlobalExceptionHandler(
ILogger<GlobalExceptionHandler> logger) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext context,
Exception exception,
CancellationToken cancellationToken)
{
var traceId = Activity.Current?.TraceId.ToString()
?? context.TraceIdentifier;
// Record classification, not the exception's sensitive payload.
logger.LogError(
"Request failed. ErrorCode={ErrorCode} ExceptionType={ExceptionType} TraceId={TraceId}",
"unexpected_error", exception.GetType().Name, traceId);
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
await context.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "The request could not be completed.",
Type = "about:blank",
Extensions =
{
["code"] = "unexpected_error",
["traceId"] = traceId
}
}, options: null, contentType: "application/problem+json",
cancellationToken: cancellationToken);
return true;
}
}04a safe response with a useful trail
exception messages and stack traces can contain paths, table names or sensitive upstream data. the client receives a fixed title and traceId instead. use that identifier to find the corresponding server record.
the example records the exception type without logging its payload. deeper diagnosis may require a restricted diagnostic channel with redaction and a retention policy. do not automatically capture request bodies, credentials or connection strings.
in .net 10, returning true suppresses the middleware’s diagnostics for the handled exception by default. this handler writes its own structured record. if you enable framework diagnostics with SuppressDiagnosticsCallback, check for duplicates. a trace identifier alone does not configure an opentelemetry exporter or alerts.
05the boundary of global handling
once a response has started, you cannot safely replace it with a new error body. streaming requires its own failure expectations. background jobs, message consumers and startup failures also sit outside this http middleware.
pass cancellation tokens to dependent operations. a disconnected client, an application deadline and an upstream timeout are different events. inspect RequestAborted before classifying cancellation as a server defect.
a failure response does not roll back completed work. a payment could succeed before the connection disappears. retries for such operations require a deliberate idempotency policy and transaction boundaries.
06test failure as a public contract
send this request and expect 500 with status 500, code unexpected_error and a nonempty traceId. neither “Demo failure” nor a stack trace should appear in the body. match the traceId to a server record. check /orders/42 for 200 and /orders/999 for 404.
exercise the actual http pipeline in integration tests. invoking the handler directly does not verify middleware order. verify that a healthy request still succeeds after the failing request.
in an operated service, test error rate alerts with a controlled fault. persistent audit records with tamper protection, distributed trace export and alert delivery require their own setup and verification. this demonstration does not claim to supply those facilities.
GET http://localhost:5080/demo/failure
Accept: application/problem+json