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.
Background and motivation
RFC 9457 defines
application/problem+jsonas 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.ProblemDetailsandMicrosoft.AspNetCore.Mvc.ProblemDetailsmake it trivial to emit. The consumer side has no support at all in the BCL. EveryHttpClientcaller that wants to surface a meaningful error today writes some variation of this:This is repeated in effectively every service client in every solution, and each copy re-declares its own
ProblemDetailsDTO. The workarounds available today are all unsatisfying:Microsoft.AspNetCore.Mvc.Core/Microsoft.AspNetCore.Http.Abstractionsfrom 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.Extensionshandling, gets the JSON property names wrong (the RFC members are lowercase), or forgets thatstatusis optional.EnsureSuccessStatusCode(). ThrowsHttpRequestExceptionwith 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 diagnosingHttpClientfailures.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
ReadFromJsonAsyncinSystem.Net.Http.Json, which is exactly where a consumer already is when this comes up.This proposal adds a model type, three
HttpResponseMessageextension methods, and an exception type.API Proposal
Behavioral specification
IsProblemJsontrueif and only ifresponse.Content?.Headers.ContentType?.MediaTypeequalsapplication/problem+json, compared withOrdinalIgnoreCase. Media type parameters (e.g.; charset=utf-8) are ignored, per theMediaTypeproperty's existing semantics.ReadProblemJsonAsyncnullwhenIsProblemJson(response)isfalse. Content is not read in that case.Extensions.Statusis absent from the payload, it is populated from(int)response.StatusCode. (See open questions — this is convenient but is inference, not parsing.)JsonExceptionon malformed content, matchingHttpContentJsonExtensions.ReadFromJsonAsync.ReadFromJsonAsync.ThrowIfProblemJsonAsyncIsProblemJson(response)isfalse, returnsresponsewithout reading content.ProblemDetailsException.EnsureSuccessStatusCode().Task<HttpResponseMessage>rather thanTaskso it can be chained (await (await client.GetAsync(...)).ThrowIfProblemJsonAsync()), mirroring the fluent shape ofEnsureSuccessStatusCode().ProblemDetailsExceptionHttpRequestException.StatusCodefromProblemDetails.Status, so existingcatch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.Conflict)filters keep working unchanged.Messageis composed from the available members, e.g."Conflict (409): Order already submitted."— falling back to the status line whenTitleandDetailare both absent.HttpRequestExceptionis the key compatibility property: addingThrowIfProblemJsonAsyncto an existing pipeline cannot break callers who are already catchingHttpRequestException.Trimming and AOT
ProblemDetailsis a closed, library-owned type, so the parameterless overloads use an internal[JsonSerializable(typeof(ProblemDetails))] JsonSerializerContextand carry noRequiresUnreferencedCode/RequiresDynamicCodeannotations. This makes them usable from trimmed and Native AOT apps without a source-generated context of the caller's own — a meaningful advantage overReadFromJsonAsync<MyProblemDto>()today.The
JsonSerializerOptionsoverloads would be annotated consistently with the rest ofSystem.Net.Http.Json.API Usage
Fluent: let the server's error become the exception
ThrowIfProblemJsonAsynchandles the case where the server explained itself;EnsureSuccessStatusCoderemains the backstop for servers that returned a bare 500 with an HTML body.Inspecting the problem at the call site
Reading ASP.NET Core validation errors from a client
An
[ApiController]returning a 400 emits theerrorsextension member. A client can read it without referencing ASP.NET Core:Centralizing in a
DelegatingHandlerBecause the extension is on
HttpResponseMessage, it drops into a typed-client pipeline in one line:Catching with the full context intact
Note the second
catch— it is unmodified pre-existing code and still works, becauseProblemDetailsExceptionsetsStatusCodeon 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.Httpinstead ofSystem.Net.Http.Json. Arguably right for a wire-format type, but the parsing extensions and theSystem.Net.Http.Jsonpackage boundary point the other way; splitting them across namespaces adds ausingfor no gain.Return
ProblemDetails?from aTryReadProblemDetailspattern. AsyncTry*methods don't have precedent in the BCL; theIsProblemJsonpredicate plus a nullable-returning read covers the same ground with existing conventions.Make
ThrowIfProblemJsonAsyncalso throw on any non-success status. Rejected: that duplicatesEnsureSuccessStatusCodeand makes the method's name inaccurate. Keeping the two orthogonal lets callers choose the order and the fallback behavior.Model
ExtensionsasIDictionary<string, object?>.JsonElementavoids 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.