dotnet / dotnet/runtime

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

Open
#131,046 4 comments 79 reactions 0 assignees View on GitHub
api-suggestion area-System.Net.Http
Dominant language
C#
Stars
18.3k
Forks
5.6k
PR merge metrics
PR metrics pending

Description

### 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:

```csharp
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();
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

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

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

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

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

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

/// Problem-type-specific extension members.
[JsonExtensionData]
public IDictionary Extensions { get; }
}

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

///
/// Reads the response content as a , or returns null
/// when the response is not a problem details response.
///
public static Task ReadProblemJsonAsync(
this HttpResponseMessage response,
CancellationToken cancellationToken = default);

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

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

public static Task 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` 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()` 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

```csharp
using System.Net.Http.Json;

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

var created = await response.Content.ReadFromJsonAsync();
```

`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

```csharp
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:

```csharp
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:

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

services.AddHttpClient()
.AddHttpMessageHandler();
```

### Catching with the full context intact

```csharp
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`.** `JsonElement` avoids polymorphic deserialization, keeps the type trim-safe, and gives callers direct access to the typed accessors (`GetString`, `EnumerateArray`, `Deserialize()`).

### 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.

Contributor guide

Open the contributing guide

Research direction

Start with the existing System.Net.Http.Json ReadFromJsonAsync entry point and review how HttpContentJsonExtensions handles options, trimming, AOT, and exceptions. Compare the proposed ProblemDetails model, response extensions, and exception against the behavioral specification; done means the API design is resolved and its parsing, status, exception, and compatibility behavior are covered by tests.

Written by the indexing model from the issue text.

Assessment

Tech stack
csharp
Domain
api, backend-api-design
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
38/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.