ProblemDetails (RFC 9457)

Return every API error as application/problem+json with AddProblemDetails, IProblemDetailsService and one CustomizeProblemDetails hook.

Syntaxbuilder.Services.AddProblemDetails(o => o.CustomizeProblemDetails = ctx => ctx.ProblemDetails.Instance = ctx.HttpContext.Request.Path); app.UseExceptionHandler(); app.UseStatusCodePages();

RFC 9457, which replaced RFC 7807, defines one JSON shape for HTTP API errors, served as application/problem+json. It has five standard members: type (a URI naming the kind of problem, the member clients branch on), title (a short summary that stays the same for that type), status (the HTTP status, repeated for convenience), detail (a human explanation of this occurrence) and instance (an identifier for this occurrence). Anything else is an extension member at the top level, such as traceId or available. ASP.NET Core models it as ProblemDetails (namespace Microsoft.AspNetCore.Mvc), whose Extensions dictionary is flattened into the JSON object.

What AddProblemDetails switches on

builder.Services.AddProblemDetails() registers IProblemDetailsService and a default JSON writer. On its own it changes no response: it is the shared service other components call when they need to write an error.

  • UseExceptionHandler() writes a 500 problem for unhandled exceptions.
  • UseStatusCodePages() fills in bare 4xx and 5xx responses such as a routing 404.
  • The developer exception page writes a problem, stack trace included, for non-HTML clients.
  • TypedResults.Problem and TypedResults.ValidationProblem go through the service when it is registered, and serialise directly, without traceId or customisation, when it is not.
  • MVC's ProblemDetailsFactory, used by Problem() and the [ApiController] client error mapping, applies the same customisation.

Defaults the writer fills in

MemberDefault when you leave it null
statusThe response status code
typeA link to the RFC 9110 section for common codes, e.g. https://tools.ietf.org/html/rfc9110#section-15.5.5 for 404; nothing for unmapped codes such as 402
titleThe reason phrase (Not Found); for 500, An error occurred while processing your request.
traceIdActivity.Current?.Id when the request has an activity (the default with logging on), otherwise HttpContext.TraceIdentifier

The default writer only writes when the Accept header allows JSON: absent, */*, application/json or application/problem+json. For Accept: text/html, TryWriteAsync returns false and the caller falls back; status code pages then writes plain text.

CustomizeProblemDetails

options.CustomizeProblemDetails runs for every problem written through the service, after the defaults. Use it for what every error must carry: instance, the service name, a tenant id. ProblemDetailsContext gives you the HttpContext, the ProblemDetails and, from the exception handler, the Exception. Keep it cheap and make sure it cannot throw: it runs on the error path, and an exception there turns a clean 404 into a 500.

Decisions a team makes

  • A stable type URI per problem, such as https://errors.example.com/out-of-stock, documented in the API reference. Clients branch on type or an errorCode extension, never on title or detail text, which you must be free to reword.
  • One shape for every error, including the 401, 404, 405 and 429 the framework produces. Mixed shapes force every client into defensive parsing.
  • detail is for people. Never copy exception.Message into it in production.

Production failure modes

Endpoints returning their own {"error": "..."} objects next to framework problems; clients that accept only application/json and reject application/problem+json; and extension values of types the JSON options cannot serialise, which under Native AOT must be known to your source-generated JSON context.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Return 409 with type https://errors.example.com/out-of-stock and the available quantity when an order line asks for more KB-104 keyboards than are in stock.
  • Give support a traceId in every error body so a customer's screenshot leads straight to the failing request in the logs.
  • Reject /orders requests without an X-Store-Id header from middleware using the same problem shape the endpoints use.

More examples

A typed client that understands problem responses

The client branches on <code>type</code>, not on <code>title</code> or <code>detail</code>, so the server can reword messages without breaking callers. <code>ProblemDetails</code> deserialises unknown members into <code>Extensions</code> as <code>JsonElement</code> values, so read them by kind instead of casting. Keeping the <code>traceId</code> in the thrown exception means the caller's own logs point straight at the failing request on the inventory service.

Example · csharp
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;

public sealed record ReserveStock(string Sku, int Quantity);

public sealed class OutOfStockException(string sku, int available)
    : Exception($"Only {available} of {sku} left.")
{
    public string Sku { get; } = sku;
    public int Available { get; } = available;
}

public sealed class InventoryClient(HttpClient http)
{
    private const string OutOfStockType = "https://errors.example.com/out-of-stock";

    public async Task ReserveAsync(ReserveStock request, CancellationToken cancellationToken)
    {
        using var response = await http.PostAsJsonAsync("/reservations", request, cancellationToken);
        if (response.IsSuccessStatusCode)
        {
            return;
        }

        if (response.Content.Headers.ContentType?.MediaType != "application/problem+json")
        {
            response.EnsureSuccessStatusCode();
        }

        var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>(cancellationToken);
        if (problem?.Type == OutOfStockType
            && problem.Extensions.TryGetValue("available", out var value)
            && value is JsonElement { ValueKind: JsonValueKind.Number } available)
        {
            throw new OutOfStockException(request.Sku, available.GetInt32());
        }

        object? traceId = null;
        problem?.Extensions.TryGetValue("traceId", out traceId);
        throw new HttpRequestException(
            $"Inventory returned {(int)response.StatusCode} {problem?.Title} (trace {traceId})",
            null,
            response.StatusCode);
    }
}

A contract test: every error is problem+json

This test pins the contract rather than one endpoint: a routing 404, a 405 and an application 404 must all come back as <code>application/problem+json</code> with a matching <code>status</code> and a <code>traceId</code>. It fails the day someone removes <code>UseStatusCodePages()</code> or adds an endpoint that writes its own error object, which is how inconsistent error shapes usually creep in.

Example · csharp
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;

public partial class Program;

public sealed class ProblemContractTests(WebApplicationFactory<Program> factory)
    : IClassFixture<WebApplicationFactory<Program>>
{
    [Theory]
    [InlineData("GET", "/orders/999999", HttpStatusCode.NotFound)]
    [InlineData("PUT", "/orders/1042", HttpStatusCode.MethodNotAllowed)]
    [InlineData("GET", "/no-such-route", HttpStatusCode.NotFound)]
    public async Task Errors_use_problem_json(string method, string path, HttpStatusCode expected)
    {
        var ct = TestContext.Current.CancellationToken;
        var client = factory.CreateClient();
        client.DefaultRequestHeaders.Add("X-Store-Id", "store-12");

        using var response = await client.SendAsync(new HttpRequestMessage(new HttpMethod(method), path), ct);

        Assert.Equal(expected, response.StatusCode);
        Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType);
        var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>(ct);
        Assert.Equal((int)expected, problem!.Status);
        Assert.True(problem.Extensions.ContainsKey("traceId"));
    }
}

The wire format clients see

Extension members sit beside the standard ones at the top level, not under a nested object, because <code>Extensions</code> is flattened on write. RFC 9457 makes <code>instance</code> a URI reference, so it holds the path and the method goes in its own extension member. The <code>type</code> URI is an identifier first; making it resolve to a documentation page that explains the problem and the fix is good practice but optional. <code>status</code> must equal the HTTP status line, which the framework does not enforce for you.

Example · json
{
  "type": "https://errors.example.com/out-of-stock",
  "title": "Not enough stock",
  "status": 409,
  "detail": "Requested 5 of KB-104, only 2 available.",
  "instance": "/orders/1042/lines",
  "traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
  "method": "POST",
  "service": "orders-api",
  "sku": "KB-104",
  "requested": 5,
  "available": 2
}

Discussion

  • Be the first to comment on this lesson.