Stop Returning HTTP 200 for Failures: RFC 9457 Problem Details in ASP.NET

HTTP already has error semantics. RFC 9457 Problem Details gives your error payloads a standard structure to match. Here's why it matters and how to implement it in ASP.NET Web API using middleware.

There is an antipattern that has been quietly spreading through APIs for decades. A client sends a request, receives an HTTP 200 OK response, parses the body, and finds something like this:

{
  "result": "fail",
  "message": "User not found"
}

HTTP said everything was fine. The body says otherwise. The client now has to do extra work to determine what actually happened, and every API that does this invents a slightly different way to communicate failure. This is a problem with a standard solution.

What HTTP 200 for Failures Actually Costs

The appeal is understandable. You have a single response wrapper that covers success and failure, the HTTP layer always returns a consistent status, and you avoid memorizing which HTTP codes apply to which scenarios. In practice, this creates several real costs.

Standard HTTP tooling breaks. Load balancers, proxies, API gateways, and monitoring tools all use HTTP status codes to route, cache, and alert. A 200 response with a failure body looks like success to every piece of infrastructure between your server and your client. Caches store it. Rate limiters do not count it. Dashboards report 100% success rates on days when half your requests are failing.

Clients must parse before they can react. With proper HTTP status codes, a client can check the status code and branch on error without touching the body. With the 200-for-failure pattern, the client has to deserialize the response, check a status field, and then decide what happened. The body must be fully parsed before error handling can begin. This is extra code on every response, and that code has to match whatever convention this particular API chose for its status field.

Error payloads have no standard structure. One API uses {"result": "fail"}. Another uses {"success": false, "error": "..."}. A third uses {"code": 1001, "description": "..."}. Clients that consume multiple APIs carry a mental model for each one, and SDKs have to handle each variant separately. There is no shared vocabulary.

The fix for the first two problems is straightforward: use HTTP status codes correctly. 4xx for client errors, 5xx for server errors, 2xx for success. This is what HTTP is for.

The fix for the third problem is RFC 9457.

When the Pattern Is Actually Intentional

Before getting to the solution, it is worth being honest about something: the 200-for-failure pattern shows up in APIs built by some of the most experienced engineers in the industry. Meta's Graph API returns HTTP 200 for nearly all responses and communicates errors through an error object in the body. Several older Google APIs do the same. This is not oversight from teams that did not know better.

The RPC framing. Many APIs are not REST, even if they run over HTTP. When the design treats HTTP as a transport layer rather than the API itself, the envelope carries application-level semantics and HTTP status carries only transport-level semantics. JSON-RPC 2.0 takes this position explicitly: the spec says HTTP status should be 200 for all application-level responses, because a 200 means the transport worked, not that the procedure succeeded. If your API is explicitly RPC-style and you want to be consistent with that model, a structured result envelope with its own status field is coherent.

Infrastructure that does not leave non-200s alone. Enterprise environments are full of proxies, WAFs, and security appliances that inspect or transform HTTP responses. Some eat 4xx and 5xx responses, trigger automatic retries that do not belong at that layer, or strip response bodies before they reach the client. Teams that have operated behind these environments often learned to return 200 because it was the only reliable way to get an actual response body through. It is an infrastructure problem, but the API pattern becomes the workaround.

Batch operations with mixed outcomes. If a single request attempts to create 50 records and 12 fail validation, what HTTP status do you return? HTTP 207 Multi-Status exists for exactly this case but is almost never used outside WebDAV. Returning 200 with a body that describes per-item success and failure is not unreasonable. The alternative is all-or-nothing semantics or asking clients to treat a partial success as an outright error.

The pattern is more defensible when you are building explicit RPC, when you own all clients and control how errors are parsed, when your API must survive hostile infrastructure, or when batch operations genuinely produce mixed outcomes with no single accurate HTTP status. It is harder to defend for resource-oriented REST APIs serving external clients, where HTTP semantics are load-bearing and clients cannot be assumed.

For most web APIs, the REST model applies and the costs are real. That is the framing for the rest of this article.

RFC 7807 and 9457: A Standard for Error Bodies

In 2016, Mark Nottingham and Erik Wilde published RFC 7807, "Problem Details for HTTP APIs." The premise is simple: if you are going to return an error, the error body should have a standard structure that clients can parse consistently regardless of which API they are talking to.

Seven years later, in July 2023, RFC 9457 obsoleted 7807 with clarifications and improvements. The core concept is unchanged, but 9457 tightens the specification in ways that matter.

The standard defines a content type, application/problem+json, and a set of fields that describe what went wrong:

  • type: A URI reference that identifies the problem type. Clients can dereference it for human-readable documentation. RFC 9457 clarifies this should be an absolute URI reference when possible.
  • title: A short, human-readable summary of the problem type. This should be stable across occurrences of the same problem, not tailored to the specific instance.
  • status: The HTTP status code. Including it in the body is redundant, but it makes the response self-contained for logging, audit trails, and cases where the status code gets stripped in transit.
  • detail: A human-readable explanation of this specific occurrence. Unlike title, detail should be specific. "Resource with ID 12345 was not found" rather than "Not Found."
  • instance: A URI reference that identifies this specific occurrence of the problem. Including the request path here gives clients and support teams something concrete to reference.

An error response looks like this:

HTTP/1.1 404 Not Found
Content-Type: application/problem+json

{
  "type": "https://tools.ietf.org/html/rfc9457",
  "title": "Not Found",
  "status": 404,
  "detail": "Resource with ID 12345 was not found",
  "instance": "/api/resources/12345"
}

The status code is on the HTTP response where every piece of infrastructure can read it. The body provides structured context that clients can parse predictably. A client that knows RFC 9457 knows how to handle errors from any API that implements it.

What RFC 9457 Changed from 7807

The differences are evolutionary rather than breaking. If you built against 7807, your implementation is still valid under 9457. The key changes:

type is more strictly defined. RFC 7807 allowed type to be "about:blank" to indicate a generic problem with no specific type URI. RFC 9457 retains this but clarifies that when type is "about:blank", title should match the HTTP status phrase and no additional semantic meaning should be attached. If you have a specific problem type, give it a real URI.

errors is now a standard extension member. RFC 7807 left validation error details entirely to extensions. RFC 9457 officially recognizes errors as a member that can carry a collection of more specific problems, which is particularly useful for validation failures where multiple fields may be invalid simultaneously.

Guidance on title localization is stricter. RFC 9457 explicitly states that title should not be localized. Localizable text belongs in detail, not title. This matters for clients that use title as a stable string to match against.

Extension members are better documented. RFC 9457 provides clearer guidance on how to define and use extension members, encouraging problem type-specific documentation at the type URI.

The practical impact for most implementations: if you were already following 7807 sensibly, you are in good shape. The biggest area to revisit is type values, where using real, dereferenceable URIs is now the clear expectation rather than an option.

Implementing It in ASP.NET Web API

ASP.NET Core has built-in support for Problem Details starting in .NET 7, through IProblemDetailsService and the IExceptionHandler interface. For greenfield projects on .NET 7 or later, the built-in support is a reasonable starting point.

For cases where you want full control over the exception-to-status-code mapping, or where you need to handle exceptions thrown by infrastructure code before they reach controller filters, middleware gives you a clean centralized location. One middleware catches all unhandled exceptions. Controllers throw domain-specific exceptions. The middleware translates them.

The pattern starts with a typed exception hierarchy. Each failure scenario gets its own exception type:

// Base exception with status code
public class ApiException : Exception
{
    public int StatusCode { get; }

    public ApiException(string message, int statusCode) : base(message)
    {
        StatusCode = statusCode;
    }
}

// Specific types for specific failure modes
public class ResourceNotFoundException : ApiException
{
    public ResourceNotFoundException(string message) : base(message, 404) { }
}

public class ServiceUnavailableException : ApiException
{
    public int? RetryAfterSeconds { get; }

    public ServiceUnavailableException(string message, int? retryAfterSeconds = null)
        : base(message, 503)
    {
        RetryAfterSeconds = retryAfterSeconds;
    }
}

public class ValidationException : ApiException
{
    public ValidationException(string message) : base(message, 400) { }
}

Domain logic throws these exceptions without knowing anything about HTTP. The middleware handles the translation:

public class ProblemDetailsMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<ProblemDetailsMiddleware> _logger;

    public ProblemDetailsMiddleware(RequestDelegate next, ILogger<ProblemDetailsMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (Exception ex)
        {
            await HandleExceptionAsync(context, ex);
        }
    }

    private async Task HandleExceptionAsync(HttpContext context, Exception exception)
    {
        _logger.LogError(exception, "An error occurred processing the request");

        var problemDetails = exception switch
        {
            ResourceNotFoundException notFound => new ProblemDetails
            {
                Type = "https://tools.ietf.org/html/rfc9457",
                Title = "Not Found",
                Status = StatusCodes.Status404NotFound,
                Detail = notFound.Message,
                Instance = context.Request.Path
            },
            ServiceUnavailableException serviceUnavailable => new ProblemDetails
            {
                Type = "https://tools.ietf.org/html/rfc9457",
                Title = "Service Unavailable",
                Status = StatusCodes.Status503ServiceUnavailable,
                Detail = serviceUnavailable.Message,
                Instance = context.Request.Path
            },
            ValidationException validation => new ProblemDetails
            {
                Type = "https://tools.ietf.org/html/rfc9457",
                Title = "Bad Request",
                Status = StatusCodes.Status400BadRequest,
                Detail = validation.Message,
                Instance = context.Request.Path
            },
            _ => new ProblemDetails
            {
                Type = "https://tools.ietf.org/html/rfc9457",
                Title = "Internal Server Error",
                Status = StatusCodes.Status500InternalServerError,
                Detail = "An unexpected error occurred",
                Instance = context.Request.Path
            }
        };

        context.Response.StatusCode = problemDetails.Status ?? StatusCodes.Status500InternalServerError;
        context.Response.ContentType = "application/problem+json";

        // Propagate Retry-After for rate limit responses
        if (exception is ServiceUnavailableException unavailableEx && unavailableEx.RetryAfterSeconds.HasValue)
        {
            context.Response.Headers.RetryAfter = unavailableEx.RetryAfterSeconds.Value.ToString();
        }

        var json = JsonSerializer.Serialize(problemDetails, new JsonSerializerOptions
        {
            PropertyNamingPolicy = JsonNamingPolicy.CamelCase
        });

        await context.Response.WriteAsync(json);
    }
}

public static class ProblemDetailsMiddlewareExtensions
{
    public static IApplicationBuilder UseProblemDetailsExceptionHandler(this IApplicationBuilder app)
    {
        return app.UseMiddleware<ProblemDetailsMiddleware>();
    }
}

Registration in Program.cs is a single line, placed early in the pipeline before any other middleware that might throw:

app.UseProblemDetailsExceptionHandler();

A few things worth noting in this implementation. The C# pattern matching switch expression maps exception types to HTTP semantics cleanly. Adding a new error scenario means adding a new exception type and one branch in the switch. The middleware does not need to change shape as the domain grows.

The Retry-After header on ServiceUnavailableException is worth calling out. RFC 9457 does not define retry behavior, but coupling a standard error format with standard HTTP headers for rate limit responses gives clients everything they need to back off and retry correctly. The structured error format and the HTTP infrastructure work together.

One improvement worth making as your API matures: the type field should point to documentation specific to the problem type rather than the RFC itself. A URI like https://api.example.com/problems/not-found that documents that specific error class is more useful than a link to the RFC. The RFC link is a reasonable default while documentation does not exist yet.

The Case for Standards

The argument for RFC 9457 is not that it is the only way to communicate errors clearly. You can design a perfectly reasonable custom error format. The argument is that a standard format that every client already knows is strictly better than a good custom format.

Clients that consume multiple APIs benefit immediately. SDKs can handle errors consistently. Monitoring tools can recognize problem details and surface them intelligently. API documentation tools like Scalar and Swagger UI have native support for application/problem+json. You get all of that for the cost of following a specification rather than inventing your own.

The old {result: "fail"} pattern does not just cost you clarity. It costs you the entire ecosystem that has grown up around HTTP standards. HTTP status codes are not boilerplate to be papered over. They are the protocol. RFC 9457 is what you reach for when the status code alone is not enough.