Skip to content

ProblemDetails & Error Handling

Key Points

  • RFC 9457 ProblemDetails is the standard error response format. Always use it for errors.
  • AddProblemDetails() registers the producer; UseExceptionHandler() (with no args, .NET 8+) catches exceptions and produces ProblemDetails.
  • IExceptionHandler (.NET 8+) is the modern handler interface — register multiple to handle different exception types.
  • Request decompression middleware accepts gzip/brotli/deflate request bodies; pair with response compression.
  • Don't leak stack traces to clients in production. Log them server-side.

Concepts (deep dive)

Setup (modern, .NET 8+)

builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<MyExceptionHandler>();

var app = builder.Build();
app.UseExceptionHandler();          // converts exceptions to ProblemDetails
app.UseStatusCodePages();           // adds ProblemDetails for 4xx/5xx without bodies

IExceptionHandler implementations

public class NotFoundExceptionHandler : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(HttpContext ctx, Exception ex, CancellationToken ct)
    {
        if (ex is not NotFoundException nf) return false;

        ctx.Response.StatusCode = 404;
        await ctx.Response.WriteAsJsonAsync(new ProblemDetails
        {
            Type = "https://example.com/probs/not-found",
            Title = "Not found",
            Status = 404,
            Detail = nf.Message,
            Instance = ctx.Request.Path
        }, cancellationToken: ct);

        return true;   // handled
    }
}

builder.Services.AddExceptionHandler<NotFoundExceptionHandler>();

Multiple handlers run in registration order. First to return true wins. If none handle, the framework's default ProblemDetails producer takes over (returns 500).

Custom problem types

Define a stable URI for each problem class — clients can match on type:

public class ValidationFailedException(IDictionary<string, string[]> errors)
    : Exception("Validation failed")
{
    public IDictionary<string, string[]> Errors { get; } = errors;
}

public class ValidationExceptionHandler : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(HttpContext ctx, Exception ex, CancellationToken ct)
    {
        if (ex is not ValidationFailedException v) return false;

        var problem = new ValidationProblemDetails(v.Errors)
        {
            Type = "https://example.com/probs/validation",
            Title = "Validation failed",
            Status = 422,
            Instance = ctx.Request.Path
        };
        ctx.Response.StatusCode = 422;
        await ctx.Response.WriteAsJsonAsync(problem, cancellationToken: ct);
        return true;
    }
}

ValidationProblemDetails extends ProblemDetails with an errors dictionary.

Adding traceId for correlation

builder.Services.AddProblemDetails(o =>
{
    o.CustomizeProblemDetails = ctx =>
    {
        ctx.ProblemDetails.Extensions["traceId"] = Activity.Current?.Id ?? ctx.HttpContext.TraceIdentifier;
        ctx.ProblemDetails.Extensions["instance"] = ctx.HttpContext.Request.Path.Value;
    };
});

Now every ProblemDetails response includes a traceId clients can quote when reporting issues — and you correlate to logs in OpenTelemetry / Application Insights.

Returning ProblemDetails manually

// Minimal API
return Results.Problem(detail: "Order not found", statusCode: 404);
return Results.ValidationProblem(errorsDict);

// MVC controller
return Problem(detail: "...", statusCode: 422);
return ValidationProblem(ModelState);

UseStatusCodePages for "naked" non-success

If a handler returns 404 with no body, UseStatusCodePages injects a ProblemDetails body:

app.UseStatusCodePages();

For richer customization:

app.UseStatusCodePages(async ctx =>
{
    var problem = new ProblemDetails
    {
        Status = ctx.HttpContext.Response.StatusCode,
        Type = $"https://example.com/probs/{ctx.HttpContext.Response.StatusCode}",
        Title = ReasonPhrases.GetReasonPhrase(ctx.HttpContext.Response.StatusCode)
    };
    await ctx.HttpContext.Response.WriteAsJsonAsync(problem);
});

Request decompression

builder.Services.AddRequestDecompression();
app.UseRequestDecompression();

Now the server accepts Content-Encoding: gzip|br|deflate request bodies and decompresses transparently. Useful for bandwidth-constrained clients (mobile, IoT).

Pair with response compression:

builder.Services.AddResponseCompression(o =>
{
    o.EnableForHttps = true;
    o.Providers.Add<BrotliCompressionProvider>();
    o.Providers.Add<GzipCompressionProvider>();
});
app.UseResponseCompression();

Don't leak in production

if (app.Environment.IsDevelopment())
    app.UseDeveloperExceptionPage();   // detailed HTML with stack
else
    app.UseExceptionHandler();         // ProblemDetails with no internals

UseDeveloperExceptionPage shows full stack traces — only enable in Dev.

Result-pattern integration

If you're using a Result type, convert to ProblemDetails at the boundary:

public static IResult ToHttp<T>(this Result<T> r) => r switch
{
    { IsSuccess: true } ok => TypedResults.Ok(ok.Value),
    { Error.Code: "NotFound" } => TypedResults.NotFound(),
    { Error.Code: "Validation" } e => TypedResults.ValidationProblem(e.Error.Errors!),
    var f => TypedResults.Problem(detail: f.Error.Message, statusCode: 500)
};

app.MapPost("/orders", async (Req req, IService s) => (await s.PlaceAsync(req)).ToHttp());

See Result Pattern vs Exceptions.


Code: correct vs wrong

❌ Wrong: returning raw exception text

catch (Exception ex)
{
    return BadRequest(ex.ToString());   // ❌ stack trace + internals
}

✅ Correct: ProblemDetails + log

catch (Exception ex)
{
    Logger.LogError(ex, "operation failed");
    return Results.Problem(statusCode: 500);   // generic; details in logs
}

❌ Wrong: hand-rolled JSON error

return Results.Json(new { error = "not found" }, statusCode: 404);

✅ Correct: ProblemDetails

return Results.Problem(statusCode: 404, title: "Not found");

Design patterns for this topic

Pattern 1 — "Single ProblemDetails shape across all errors"

  • Intent: clients have one error parser.

Pattern 2 — "Custom IExceptionHandler per exception class"

  • Intent: translate domain exceptions to HTTP semantics.

Pattern 3 — "traceId extension for correlation"

  • Intent: clients can report the trace; you find the request in logs.

Pattern 4 — "DeveloperExceptionPage in Dev; ProblemDetails in Prod"

  • Intent: rich diagnostics locally; safe responses in prod.

Pros & cons / trade-offs

Approach Pros Cons
ProblemDetails Standard Verbose schema for simple errors
IExceptionHandler Per-type handling Multiple registrations
Result pattern Explicit API-conversion code
Exception filters MVC-specific Not first-class

When to use / when to avoid

  • Use ProblemDetails for every error response.
  • Use IExceptionHandler for app-wide unhandled exceptions.
  • Avoid raw exception text in responses.
  • Avoid leaking server internals to clients.

Interview Q&A

Q1. What's RFC 9457 ProblemDetails? Standard error response shape: type, title, status, detail, instance, plus extensions. ASP.NET Core has built-in support.

Q2. What's IExceptionHandler? Modern (.NET 8+) interface for converting exceptions to HTTP responses. Register one or more; first to handle wins.

Q3. Difference between UseExceptionHandler and UseDeveloperExceptionPage? The former produces ProblemDetails (production-safe). The latter shows full stack traces (Dev only).

Q4. How do you add traceId to all ProblemDetails? AddProblemDetails(o => o.CustomizeProblemDetails = ctx => { ... }) — append Extensions["traceId"].

Q5. What's ValidationProblemDetails? Extends ProblemDetails with an errors dictionary mapping field name → array of error messages.

Q6. How does UseStatusCodePages differ from UseExceptionHandler? UseStatusCodePages adds bodies to existing 4xx/5xx responses without bodies. UseExceptionHandler catches exceptions and produces a response.

Q7. Should you return 200 OK with { error: ... } body? No — lies about success. Use the proper status code + ProblemDetails.

Q8. What does request decompression middleware do? Decompresses incoming Content-Encoding: gzip|br|deflate request bodies transparently.

Q9. Where do you put auth-failure responses? UseAuthorization returns 401/403; UseStatusCodePages adds ProblemDetails body.

Q10. How do you map a domain exception to an HTTP response without coupling controllers to HTTP? IExceptionHandler per exception type. Domain throws NotFoundException; handler returns 404 + ProblemDetails. Controllers stay HTTP-naive.


Gotchas / common mistakes

  • ⚠️ Stack traces in production — info disclosure.
  • ⚠️ Results.Ok(new { error }) — wrong status code.
  • ⚠️ Forgetting AddProblemDetailsUseExceptionHandler may not produce ProblemDetails.
  • ⚠️ Multiple IExceptionHandler order — first matching wins; document order intent.
  • ⚠️ Throwing in IExceptionHandler — surfaces as 500.

Further reading