Status Code Pages for Empty Responses

Turn bare 404, 405, 401 and 429 responses into ProblemDetails with UseStatusCodePages, and opt out the endpoints that need an empty body.

Syntaxbuilder.Services.AddProblemDetails(); app.UseExceptionHandler(); app.UseStatusCodePages(); app.MapGet("/ready", () => Results.StatusCode(503)).WithMetadata(new SkipStatusCodePagesAttribute());

Many error responses in an API carry a status code and no body: routing finds no endpoint (404); the path exists but not for that method (405, with an Allow header); an endpoint returns TypedResults.NotFound() or Results.StatusCode(409); an authentication handler challenges (401) or forbids (403); body binding rejects malformed JSON (400) or the wrong content type (415); the rate limiter rejects a request (503 unless you set RejectionStatusCode). A client that expects problem+json then gets an empty body, and some client libraries report a JSON parse error instead of the status.

How the middleware works

app.UseStatusCodePages() sets an IStatusCodePagesFeature on the request, runs the rest of the pipeline and, on the way back, writes a body only when all four hold: the status is 400 to 599, the response has not started, there is no Content-Length and there is no Content-Type. With AddProblemDetails() registered the body is a problem with the default type and title for the code plus traceId. Codes without a default mapping, such as 429, get the reason phrase as title and no type; set one in CustomizeProblemDetails if clients branch on type. Without it the body is plain text: Status Code: 404; Not Found. A client sending Accept: text/html also gets the plain text, because the problem writer declines non-JSON requests.

A response that already has a body is left alone, so TypedResults.NotFound(new { message }) keeps its own JSON. That is correct, and also how a hand-written {"error": "..."} escapes the standard shape unnoticed.

Placement

Register it right after UseExceptionHandler() and before authentication, the rate limiter and endpoints, so it sees every code they set. It does not handle exceptions, and because exceptions bypass it on the way out, the two never write the same response; for clients that do not accept JSON the exception handler leaves the 500 body empty. In MVC, [ApiController] already turns NotFound() and similar results into problems through its client error mapping (SuppressMapClientErrors switches that off), but routing 404s and 405s never reach MVC, so controller apps need status code pages too.

Variants

CallBehaviourUse for
UseStatusCodePages()Problem details, or plain text without the serviceAPIs: the default choice
UseStatusCodePages(Func<StatusCodeContext, Task>)Your code writes the bodyExtra members, e.g. allowed methods on 405
UseStatusCodePagesWithReExecute("/errors/{0}")Re-runs the pipeline for an error page, keeps URL and statusHTML pages in the same app
UseStatusCodePagesWithRedirects("/errors/{0}")Responds 302 to the error pageNever for APIs: clients follow it and see a 200 HTML page

Opting out

Add SkipStatusCodePagesAttribute as endpoint metadata, or set IStatusCodePagesFeature.Enabled = false during the request, for endpoints whose callers read only the status, such as a readiness probe or a webhook receiver whose sender treats any body as a payload.

Production failure modes

Rate limiter rejections arriving as 503 and paging the on-call engineer because nobody set RejectionStatusCode = 429; an ingress or API gateway configured with custom error pages that replaces your problem bodies with HTML; and a web app template's redirect variant copied into an API.

Example

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

When to use it

  • Give a mobile client a problem+json body for GET /api/orders/7 when the order does not exist, instead of an empty 404 its JSON parser chokes on.
  • Return a 429 problem with a traceId when a customer exports orders.csv twice within a minute, instead of the rate limiter's default empty 503.
  • Keep /internal/ready returning an empty 503 for the Kubernetes probe while every other endpoint returns problem details.

More examples

A custom handler that lists allowed methods on 405

The delegate overload replaces the default body writer, so it must handle every status, not only the one you care about. Going through <code>IProblemDetailsService</code> keeps the defaults (<code>type</code>, <code>title</code>, <code>traceId</code>) and <code>CustomizeProblemDetails</code>. The <code>Allow</code> header set by routing is already on the response, so the handler can echo it into the body for clients that do not read headers.

Example · csharp
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();

var app = builder.Build();
app.UseExceptionHandler();

app.UseStatusCodePages(async statusCodeContext =>
{
    var httpContext = statusCodeContext.HttpContext;
    var problem = new ProblemDetails { Status = httpContext.Response.StatusCode };

    if (httpContext.Response.StatusCode == StatusCodes.Status405MethodNotAllowed)
    {
        problem.Detail = $"{httpContext.Request.Method} is not supported on {httpContext.Request.Path}.";
        problem.Extensions["allowedMethods"] = httpContext.Response.Headers.Allow.ToString().Split(", ");
    }

    var problems = httpContext.RequestServices.GetRequiredService<IProblemDetailsService>();
    if (!await problems.TryWriteAsync(new ProblemDetailsContext { HttpContext = httpContext, ProblemDetails = problem }))
    {
        await httpContext.Response.WriteAsync($"{httpContext.Response.StatusCode}");
    }
});

app.MapGet("/orders/{id:int}", (int id) => Results.Ok(new { id }));
app.MapDelete("/orders/{id:int}", (int id) => Results.NoContent());

app.Run();

HTML error pages for the site, problems for /api

One app serving both a storefront and an API needs two behaviours: browsers get a friendly page, API clients get problem+json. <code>UseWhen</code> branches the pipeline by path and rejoins it, so each branch registers its own status code pages. Re-execute keeps the original URL and status code, which matters for search engines; the redirect variant would answer 302 and then 200.

Example · csharp
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();

var app = builder.Build();
app.UseExceptionHandler();

app.UseWhen(
    context => context.Request.Path.StartsWithSegments("/api"),
    api => api.UseStatusCodePages());

app.UseWhen(
    context => !context.Request.Path.StartsWithSegments("/api"),
    site => site.UseStatusCodePagesWithReExecute("/errors/{0}"));

app.MapGet("/api/products/{sku}", (string sku) =>
    sku == "KB-104" ? Results.Ok(new { sku, name = "Mechanical keyboard" }) : Results.NotFound());

app.MapGet("/errors/{code:int}", (int code) =>
    Results.Content($"<h1>{code}</h1><p>That page does not exist. <a href=\"/\">Back to the shop</a></p>", "text/html"));

app.Run();

Test that framework errors keep the contract

Framework-generated errors are the ones nobody writes code for, so they regress silently when someone reorders middleware or removes <code>AddProblemDetails()</code>. These tests pin both sides: the 405 keeps its <code>Allow</code> header and gains a problem body, and the opted-out readiness endpoint stays empty for the load balancer.

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

public partial class Program;

public sealed class StatusCodePageTests(WebApplicationFactory<Program> factory)
    : IClassFixture<WebApplicationFactory<Program>>
{
    [Fact]
    public async Task Wrong_method_returns_problem_with_allow_header()
    {
        var ct = TestContext.Current.CancellationToken;
        var client = factory.CreateClient();

        using var response = await client.PutAsync("/orders/1042", content: null, ct);

        Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode);
        Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType);
        Assert.Contains("GET", response.Content.Headers.Allow);
    }

    [Fact]
    public async Task Readiness_probe_keeps_an_empty_body()
    {
        var ct = TestContext.Current.CancellationToken;
        var client = factory.CreateClient();

        using var response = await client.GetAsync("/internal/ready", ct);

        Assert.Equal(0, response.Content.Headers.ContentLength ?? 0);
        Assert.Null(response.Content.Headers.ContentType);
    }
}

Discussion

  • Be the first to comment on this lesson.