Errors as ProblemDetails

Returning machine-readable errors in the RFC 9457 problem format, and handling exceptions without leaking a stack trace.

Syntaxbuilder.Services.AddProblemDetails(); app.UseExceptionHandler();

When a request fails, the client needs to know why in a form it can read without parsing English. RFC 9457 defines one: a JSON object with the members type, title, status, detail and instance, sent with the content type application/problem+json. (It replaced RFC 7807; the format is unchanged, so older documents describe the same thing.) ASP.NET Core models it as the ProblemDetails class, and anything extra you add goes in its Extensions dictionary.

Turning it on

builder.Services.AddProblemDetails() registers the service that writes this format, and the framework then fills in a problem body for responses that would otherwise be an empty error status. For a failure you detect yourself, return TypedResults.Problem. Validation failures already use a subclass, HttpValidationProblemDetails, which adds an errors member mapping each field to its messages.

Unhandled exceptions

Add app.UseExceptionHandler() to catch what escapes a handler, then register one or more IExceptionHandler implementations with AddExceptionHandler<T>(). Its single method is ValueTask<bool> TryHandleAsync(HttpContext, Exception, CancellationToken). Return true if you wrote the response, false to let the next handler try. They run in registration order and are resolved as singletons.

Never leak

A stack trace names your types, file paths and library versions, and an exception message often carries the data that caused it, sometimes a connection string. Log the exception, send a generic title, and add a trace identifier to the response so support can find the log entry that matches.

Example

Example · csharp
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);

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

var app = builder.Build();

app.UseExceptionHandler();

app.MapGet("/boom", () =>
{
    // The message carries a secret. It must not reach the client.
    throw new InvalidOperationException("Server=db;Password=hunter2");
});

app.Run();

public class UnhandledExceptionHandler : IExceptionHandler
{
    private readonly IProblemDetailsService _problemDetails;
    private readonly ILogger<UnhandledExceptionHandler> _logger;

    public UnhandledExceptionHandler(
        IProblemDetailsService problemDetails,
        ILogger<UnhandledExceptionHandler> logger)
    {
        _problemDetails = problemDetails;
        _logger = logger;
    }

    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken cancellationToken)
    {
        // The detail stays in the log, where only you can read it.
        _logger.LogError(exception, "Unhandled exception on {Path}", httpContext.Request.Path);

        httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;

        return await _problemDetails.TryWriteAsync(new ProblemDetailsContext
        {
            HttpContext = httpContext,
            ProblemDetails = new ProblemDetails
            {
                Title = "An unexpected error occurred.",
                Status = StatusCodes.Status500InternalServerError
            }
        });
    }
}

When to use it

  • Give a client a 409 it can act on, with a machine-readable reason, when two users edit the same record.
  • Replace a raw 500 and a stack trace with a generic problem body and a trace id support can look up.
  • Return one error format from every endpoint so a mobile client needs a single parser.
  • Map a domain exception such as InsufficientFunds to a specific status code in one place.

More examples

What arrives on the wire

This is the body a validation failure produces: the five RFC 9457 members, plus errors from HttpValidationProblemDetails and traceId added by the app. A client reads status to decide what to do and errors to decide which field to highlight; title and detail are for humans. Any member the specification does not define is an extension, which is why traceId and errors sit alongside the rest rather than nested.

Example · json
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "instance": "/signups",
  "traceId": "0HNFA2M9K3L4D:00000001",
  "errors": {
    "Email": [ "The Email field is not a valid e-mail address." ],
    "Age": [ "The field Age must be between 13 and 120." ]
  }
}

Adding a trace id to every problem

CustomizeProblemDetails is the one place to add members that belong on every error, so individual handlers do not repeat themselves. Pairing the response with HttpContext.TraceIdentifier means a user can quote one string and you can find the exact log entry, without the response ever containing the exception. The GET shows the deliberate form: TypedResults.Problem for a failure your own code detected.

Example · csharp
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddProblemDetails(options =>
{
    // Runs for every problem body the framework generates,
    // including validation failures and bare error statuses.
    options.CustomizeProblemDetails = context =>
    {
        context.ProblemDetails.Instance =
            $"{context.HttpContext.Request.Method} {context.HttpContext.Request.Path}";

        // The same value appears in every log entry for this request.
        context.ProblemDetails.Extensions["traceId"] = context.HttpContext.TraceIdentifier;
    };
});

var app = builder.Build();

app.UseExceptionHandler();

app.MapGet("/orders/{id:int}", (int id) => TypedResults.Problem(
    title: "Order not found",
    detail: $"No order with id {id} exists.",
    statusCode: StatusCodes.Status404NotFound));

app.Run();

One handler per exception type

A chain of small handlers keeps the mapping from exception type to status code in one readable place, instead of a try/catch in every endpoint. The if-and-return-false pattern is what makes the chain work. Note that exception handlers are singletons, so inject a factory or resolve from HttpContext.RequestServices rather than taking a scoped service such as a database context in the constructor.

Example · csharp
using Microsoft.AspNetCore.Diagnostics;

// Registration order decides which handler sees the exception first:
//   builder.Services.AddExceptionHandler<ConcurrencyExceptionHandler>();
//   builder.Services.AddExceptionHandler<UnhandledExceptionHandler>();
public class ConcurrencyExceptionHandler : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken cancellationToken)
    {
        if (exception is not ConcurrencyException concurrency)
        {
            // Not ours. Returning false passes it to the next handler.
            return false;
        }

        await TypedResults.Problem(
                title: "The record changed while you were editing it.",
                detail: $"Reload {concurrency.Resource} and apply your change again.",
                statusCode: StatusCodes.Status409Conflict)
            .ExecuteAsync(httpContext);

        return true;
    }
}

public class ConcurrencyException : Exception
{
    public ConcurrencyException(string resource)
        : base($"Concurrent update of {resource}.")
    {
        Resource = resource;
    }

    public string Resource { get; }
}

Discussion

  • Be the first to comment on this lesson.