Skip to content

[API Proposal]: Problem Details (RFC 9457) support in System.Net.Http.Json #131046

Description

@pwelter34

Background and motivation

RFC 9457 defines application/problem+json as the standard machine-readable error format for HTTP APIs. It is the default error shape produced by ASP.NET Core (Results.Problem, ProblemDetailsMiddleware, [ApiController] model-state responses), Spring Boot, FastAPI, and most modern API frameworks.

The producer side of this contract is well covered in .NET: Microsoft.AspNetCore.Http.ProblemDetails and Microsoft.AspNetCore.Mvc.ProblemDetails make it trivial to emit. The consumer side has no support at all in the BCL. Every HttpClient caller that wants to surface a meaningful error today writes some variation of this:

var response = await client.PostAsJsonAsync("/orders", order);

if (!response.IsSuccessStatusCode)
{
    if (response.Content.Headers.ContentType?.MediaType == "application/problem+json")
    {
        var problem = await response.Content.ReadFromJsonAsync<MyProblemDetails>();
        throw new MyApiException(problem?.Title, problem?.Detail, response.StatusCode);
    }

    response.EnsureSuccessStatusCode();
}

This is repeated in effectively every service client in every solution, and each copy re-declares its own ProblemDetails DTO. The workarounds available today are all unsatisfying:

  • Reference Microsoft.AspNetCore.Mvc.Core / Microsoft.AspNetCore.Http.Abstractions from a client library. Pulls ASP.NET Core into console apps, MAUI apps, Blazor WASM clients, and class libraries that have no business depending on a server framework.
  • Hand-roll a DTO per project. Works, but every copy tends to drop Extensions handling, gets the JSON property names wrong (the RFC members are lowercase), or forgets that status is optional.
  • EnsureSuccessStatusCode(). Throws HttpRequestException with a message containing only the status code and reason phrase. The server carefully explained why the request failed and that payload is thrown away — the single most common complaint about diagnosing HttpClient failures.

Problem details is a stable, versioned, IETF-standard format with no framework dependency; the parsing and the "throw if the server sent an error document" pattern belong next to ReadFromJsonAsync in System.Net.Http.Json, which is exactly where a consumer already is when this comes up.

This proposal adds a model type, three HttpResponseMessage extension methods, and an exception type.

API Proposal

namespace System.Net.Http.Json
{
    public class ProblemDetails
    {
        /// <summary>A URI reference that identifies the problem type. Defaults to "about:blank" semantics when absent.</summary>
        [JsonPropertyName("type")]
        public string? Type { get; set; }

        /// <summary>A short, human-readable summary of the problem type.</summary>
        [JsonPropertyName("title")]
        public string? Title { get; set; }

        /// <summary>The HTTP status code generated by the origin server.</summary>
        [JsonPropertyName("status")]
        public int? Status { get; set; }

        /// <summary>A human-readable explanation specific to this occurrence of the problem.</summary>
        [JsonPropertyName("detail")]
        public string? Detail { get; set; }

        /// <summary>A URI reference that identifies the specific occurrence of the problem.</summary>
        [JsonPropertyName("instance")]
        public string? Instance { get; set; }

        /// <summary>Problem-type-specific extension members.</summary>
        [JsonExtensionData]
        public IDictionary<string, JsonElement> Extensions { get; }
    }

    public static class HttpResponseMessageJsonExtensions
    {
        /// <summary>
        /// Returns true when the response content media type is "application/problem+json".
        /// </summary>
        public static bool IsProblemJson(this HttpResponseMessage response);

        /// <summary>
        /// Reads the response content as a <see cref="ProblemDetails"/>, or returns null
        /// when the response is not a problem details response.
        /// </summary>
        public static Task<ProblemDetails?> ReadProblemJsonAsync(
            this HttpResponseMessage response,
            CancellationToken cancellationToken = default);

        public static Task<ProblemDetails?> ReadProblemJsonAsync(
            this HttpResponseMessage response,
            JsonSerializerOptions? options,
            CancellationToken cancellationToken = default);

        /// <summary>
        /// Throws <see cref="ProblemDetailsException"/> if the response is a problem details
        /// response; otherwise returns the response unchanged, so the call composes into a
        /// fluent chain.
        /// </summary>
        public static Task<HttpResponseMessage> ThrowIfProblemJsonAsync(
            this HttpResponseMessage response,
            CancellationToken cancellationToken = default);

        public static Task<HttpResponseMessage> ThrowIfProblemJsonAsync(
            this HttpResponseMessage response,
            JsonSerializerOptions? options,
            CancellationToken cancellationToken = default);
    }

    public class ProblemDetailsException : HttpRequestException
    {
        public ProblemDetailsException(ProblemDetails problemDetails);
        public ProblemDetailsException(ProblemDetails problemDetails, Exception? innerException);

        public ProblemDetails ProblemDetails { get; }
    }
}

Behavioral specification

IsProblemJson

  • Returns true if and only if response.Content?.Headers.ContentType?.MediaType equals application/problem+json, compared with OrdinalIgnoreCase. Media type parameters (e.g. ; charset=utf-8) are ignored, per the MediaType property's existing semantics.
  • Does not consider the status code. RFC 9457 does not forbid a problem document on a 2xx response, and coupling the two would make the predicate lie about what was actually received.
  • Never throws, never buffers, never consumes content.

ReadProblemJsonAsync

  • Returns null when IsProblemJson(response) is false. Content is not read in that case.
  • Otherwise deserializes the content and returns a non-null instance. Unknown members land in Extensions.
  • If Status is absent from the payload, it is populated from (int)response.StatusCode. (See open questions — this is convenient but is inference, not parsing.)
  • Propagates JsonException on malformed content, matching HttpContentJsonExtensions.ReadFromJsonAsync.
  • Consumes the content stream, exactly like ReadFromJsonAsync.

ThrowIfProblemJsonAsync

  • If IsProblemJson(response) is false, returns response without reading content.
  • Otherwise reads the problem details and throws ProblemDetailsException.
  • Deliberately does not throw for non-problem error responses. It composes with, rather than replaces, EnsureSuccessStatusCode().
  • Returns Task<HttpResponseMessage> rather than Task so it can be chained (await (await client.GetAsync(...)).ThrowIfProblemJsonAsync()), mirroring the fluent shape of EnsureSuccessStatusCode().

ProblemDetailsException

  • Sets HttpRequestException.StatusCode from ProblemDetails.Status, so existing catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.Conflict) filters keep working unchanged.
  • Message is composed from the available members, e.g. "Conflict (409): Order already submitted." — falling back to the status line when Title and Detail are both absent.
  • Deriving from HttpRequestException is the key compatibility property: adding ThrowIfProblemJsonAsync to an existing pipeline cannot break callers who are already catching HttpRequestException.

Trimming and AOT

ProblemDetails is a closed, library-owned type, so the parameterless overloads use an internal [JsonSerializable(typeof(ProblemDetails))] JsonSerializerContext and carry no RequiresUnreferencedCode / RequiresDynamicCode annotations. This makes them usable from trimmed and Native AOT apps without a source-generated context of the caller's own — a meaningful advantage over ReadFromJsonAsync<MyProblemDto>() today.

The JsonSerializerOptions overloads would be annotated consistently with the rest of System.Net.Http.Json.

API Usage

Fluent: let the server's error become the exception

using System.Net.Http.Json;

var response = await client.PostAsJsonAsync("/orders", order);
await response.ThrowIfProblemJsonAsync();
response.EnsureSuccessStatusCode();

var created = await response.Content.ReadFromJsonAsync<Order>();

ThrowIfProblemJsonAsync handles the case where the server explained itself; EnsureSuccessStatusCode remains the backstop for servers that returned a bare 500 with an HTML body.

Inspecting the problem at the call site

var response = await client.PostAsJsonAsync("/orders", order);

if (response.IsProblemJson())
{
    var problem = await response.ReadProblemJsonAsync();

    logger.LogWarning("Order rejected: {Type} {Title} — {Detail}",
        problem!.Type, problem.Title, problem.Detail);

    if (problem.Extensions.TryGetValue("traceId", out var traceId))
    {
        logger.LogWarning("Server trace id {TraceId}", traceId.GetString());
    }

    return Result.Failure(problem.Detail ?? problem.Title);
}

Reading ASP.NET Core validation errors from a client

An [ApiController] returning a 400 emits the errors extension member. A client can read it without referencing ASP.NET Core:

var problem = await response.ReadProblemJsonAsync();

if (problem?.Extensions.TryGetValue("errors", out var errors) == true)
{
    foreach (var field in errors.EnumerateObject())
    {
        foreach (var message in field.Value.EnumerateArray())
        {
            editContext.AddValidationMessage(field.Name, message.GetString()!);
        }
    }
}

Centralizing in a DelegatingHandler

Because the extension is on HttpResponseMessage, it drops into a typed-client pipeline in one line:

public sealed class ProblemDetailsHandler : DelegatingHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken cancellationToken)
    {
        var response = await base.SendAsync(request, cancellationToken);
        return await response.ThrowIfProblemJsonAsync(cancellationToken);
    }
}

services.AddHttpClient<OrderClient>()
        .AddHttpMessageHandler<ProblemDetailsHandler>();

Catching with the full context intact

try
{
    await orderClient.SubmitAsync(order);
}
catch (ProblemDetailsException ex) when (ex.ProblemDetails.Type == "https://errors.contoso.com/insufficient-funds")
{
    return RedirectToPayment(ex.ProblemDetails.Detail);
}
catch (HttpRequestException ex) when (ex.StatusCode >= HttpStatusCode.InternalServerError)
{
    return ServerUnavailable();
}

Note the second catch — it is unmodified pre-existing code and still works, because ProblemDetailsException sets StatusCode on its base.

Alternative Designs

Do nothing / leave it to Microsoft.AspNetCore.*. Rejected: it forces a server framework dependency onto pure clients (MAUI, WASM, console, SDK packages). The format is IETF-standard and framework-neutral; the consumer story belongs in the BCL.

Name the type HttpProblemDetails. Avoids the collision described below, at the cost of diverging from the name every other .NET API and every other ecosystem uses for the same document.

Put the type in System.Net.Http instead of System.Net.Http.Json. Arguably right for a wire-format type, but the parsing extensions and the System.Net.Http.Json package boundary point the other way; splitting them across namespaces adds a using for no gain.

Return ProblemDetails? from a TryReadProblemDetails pattern. Async Try* methods don't have precedent in the BCL; the IsProblemJson predicate plus a nullable-returning read covers the same ground with existing conventions.

Make ThrowIfProblemJsonAsync also throw on any non-success status. Rejected: that duplicates EnsureSuccessStatusCode and makes the method's name inaccurate. Keeping the two orthogonal lets callers choose the order and the fallback behavior.

Model Extensions as IDictionary<string, object?>. JsonElement avoids polymorphic deserialization, keeps the type trim-safe, and gives callers direct access to the typed accessors (GetString, EnumerateArray, Deserialize<T>()).

Risks

Low. All additive surface, one new namespace-level type name whose collision risk is called out above, and one new exception type that derives from the exception callers already catch. No behavior of an existing API changes.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions