DEV Community

Anaya Upadhyay
Anaya Upadhyay

Posted on

Five HTTP Status Codes Your API Is Probably Misusing

A cache served one of my failures as a success for forty minutes once, and the root cause was a single line: an endpoint returning 200 OK with an error object in the body. Nothing crashed. Nothing alerted. The status line said everything was fine, and every machine between my server and the user believed it.

Status codes are not decoration on a response. They are the machine-readable half of your API contract, and RFC 9110 wrote the terms. Here are the five pairs I see misused most, what the spec actually promises for each, and the one-line fix on .NET 10 minimal APIs.

All samples target .NET 10 (the current LTS). The full runnable program is at the bottom.

1. 200 vs 204: success with a body vs success without one

A 200 promises a representation in the body (RFC 9110 §15.3.1). A 204 promises success and explicitly no content (§15.3.5). The common miss is a DELETE endpoint returning an empty 200, which hands the client a success it now has to parse into nothing.

// Misused: 200 with nothing to say
app.MapDelete("/orders/{id:int}", async (int id, Db db) =>
{
    await db.DeleteOrderAsync(id);
    return TypedResults.Ok(); // body promised, none delivered
});

// Per the contract
app.MapDelete("/orders/{id:int}", async (int id, Db db) =>
{
    await db.DeleteOrderAsync(id);
    return TypedResults.NoContent(); // 204: done, nothing follows
});
Enter fullscreen mode Exit fullscreen mode

Why it matters beyond pedantry: generated clients and strict HTTP libraries treat the two differently. A 204 lets them skip body handling entirely; an empty 200 makes some of them throw on deserialization.

2. 201 needs a Location, and the resource has to exist first

201 Created carries two obligations from §15.3.2: the resource exists before the response ships, and the response identifies it, normally through the Location header (§10.2.2). Returning a bare 200 from a POST that created something hides the new resource's address; the client created a thing it cannot find without guessing your URL scheme.

app.MapPost("/orders", async (OrderDto dto, Db db) =>
{
    var order = await db.CreateOrderAsync(dto);
    return TypedResults.Created($"/orders/{order.Id}", order);
});
Enter fullscreen mode Exit fullscreen mode

TypedResults.Created sets the Location header for you. If creation is deferred to a queue, 201 is the wrong code entirely; 202 Accepted (§15.3.3) is the honest one, because nothing exists yet.

3. 400 vs 422: could not read it vs read it and refused

400 Bad Request (§15.5.1) is for requests the server cannot or will not process as sent: malformed JSON, broken framing, garbage syntax. 422 Unprocessable Content (§15.5.21) is for requests that parsed fine but fail the rules: a missing required field, a value out of range, a business constraint.

The distinction tells the caller where to look. A 400 says fix your serializer; a 422 says fix your data.

app.MapPost("/orders", async (OrderDto dto, Db db) =>
{
    var errors = Validate(dto);
    if (errors.Count > 0)
        return TypedResults.UnprocessableEntity(errors);

    var order = await db.CreateOrderAsync(dto);
    return TypedResults.Created($"/orders/{order.Id}", order);
});
Enter fullscreen mode Exit fullscreen mode

One naming wrinkle worth knowing: the .NET helper kept the pre-9110 name (UnprocessableEntity, from the WebDAV-era RFC 4918), but the wire status RFC 9110 defines is 422 Unprocessable Content. Same number, updated name, and 9110 is the Internet Standard.

If your team standardizes on 400 for validation instead, that is a defensible convention; ASP.NET Core's own ValidationProblem defaults there. The failure mode is mixing both meanings in one API. Pick one and write it down. Either way, put the details in a ProblemDetails body (RFC 9457, which obsoleted RFC 7807) so clients get structure, not prose.

4. 401 vs 403: who are you vs no

401 Unauthorized (§15.5.2) means the request lacks valid credentials, and it tells the client to authenticate and retry. 403 Forbidden (§15.5.4) means the server understood exactly who is asking and the answer is no; repeating the request with the same credentials changes nothing.

The classic swap is returning 403 on a missing token. That is wrong because the 401 is the signal client frameworks use to trigger a login or token refresh. Send 403 instead and users stare at a dead screen where a re-auth prompt should have appeared.

app.MapGet("/admin/reports", (ClaimsPrincipal user) =>
{
    if (user.Identity?.IsAuthenticated != true)
        return Results.Unauthorized();          // 401: prove who you are

    if (!user.IsInRole("Admin"))
        return Results.Forbid();                // 403: we know, and no

    return Results.Ok(BuildReport());
});
Enter fullscreen mode Exit fullscreen mode

In a real app the auth middleware handles most of this; the swap usually creeps in through hand-rolled checks inside endpoints, exactly like the one above.

5. 500 vs 503: we broke vs come back later

500 Internal Server Error (§15.6.1) is an unexpected failure, a bug's calling card. 503 Service Unavailable (§15.6.4) is a deliberate signal: overloaded, or down for maintenance, expected to recover, and it can carry a Retry-After header telling clients when to return.

Returning 500 during a known dependency outage or a planned window is wrong because everything downstream classifies 500 as a defect: alerting pages a human, retry policies either hammer you or give up, and load balancers may not shed traffic the way they would on a 503.

app.MapGet("/quotes", async (QuoteService svc, HttpContext ctx) =>
{
    if (!svc.IsAvailable)
    {
        ctx.Response.Headers.RetryAfter = "120";
        return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
    }
    return Results.Ok(await svc.GetQuotesAsync());
});
Enter fullscreen mode Exit fullscreen mode

Retry-After turns a failure into a schedule.

Three machines read your status before any human does

This is why the exact code matters more than the body text:

Caches. RFC 9110 §15.1 lists the status codes that are heuristically cacheable, and 200 is on the list. Ship an error inside a 200 with permissive cache headers, and an intermediary can legitimately store your failure and serve it as success until it expires. A 500 would never have been cached.

Retry policies. Resilience libraries like Polly classify by status class. 5xx and 408 are typically retryable; 4xx means the request itself is wrong and retrying is waste. A validation failure returned as 500 invites a retry storm for a typo. A genuine outage returned as 200-with-error-body gets no retry at all.

Monitoring. Dashboards and SLOs key on status classes. The 200-envelope pattern, where every response is 200 and the "real" status lives in a JSON field, makes your error rate permanently zero on every standard tool. The API can be on fire and the graphs stay green.

The body is for people. The status line is for everything standing between you and them.

The checklist

  • Deleted, nothing to return → 204
  • Created → 201 plus Location (deferred creation → 202)
  • Could not parse the request → 400
  • Parsed, fails validation or business rules → 422 (or a documented 400 convention, never both meanings at once)
  • No credentials or bad ones → 401
  • Valid credentials, no rights → 403
  • Unexpected failure → 500
  • Down on purpose or overloaded → 503 plus Retry-After ## Runnable program
using System.Security.Claims;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<Db>();
builder.Services.AddSingleton<QuoteService>();
var app = builder.Build();

app.MapDelete("/orders/{id:int}", async (int id, Db db) =>
{
    await db.DeleteOrderAsync(id);
    return TypedResults.NoContent();
});

app.MapPost("/orders", async (OrderDto dto, Db db) =>
{
    var errors = new Dictionary<string, string[]>();
    if (dto.Total <= 0)
        errors["total"] = ["must be greater than zero"];
    if (errors.Count > 0)
        return Results.UnprocessableEntity(errors);

    var order = await db.CreateOrderAsync(dto);
    return Results.Created($"/orders/{order.Id}", order);
});

app.MapGet("/admin/reports", (ClaimsPrincipal user) =>
    user.Identity?.IsAuthenticated != true ? Results.Unauthorized()
    : !user.IsInRole("Admin") ? Results.Forbid()
    : Results.Ok(new { generated = DateTime.UtcNow }));

app.MapGet("/quotes", async (QuoteService svc, HttpContext ctx) =>
{
    if (!svc.IsAvailable)
    {
        ctx.Response.Headers.RetryAfter = "120";
        return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
    }
    return Results.Ok(await svc.GetQuotesAsync());
});

app.Run();

record OrderDto(decimal Total);
record Order(int Id, decimal Total);

class Db
{
    public Task DeleteOrderAsync(int id) => Task.CompletedTask;
    public Task<Order> CreateOrderAsync(OrderDto dto) =>
        Task.FromResult(new Order(Random.Shared.Next(1, 9999), dto.Total));
}

class QuoteService
{
    public bool IsAvailable => true;
    public Task<string[]> GetQuotesAsync() =>
        Task.FromResult(new[] { "steady", "as", "she", "goes" });
}
Enter fullscreen mode Exit fullscreen mode

Sources worth keeping open: RFC 9110 (HTTP Semantics, the status code sections cited above), RFC 9457 (ProblemDetails), RFC 9111 (caching), and the ASP.NET Core minimal API docs for the TypedResults helpers.

I post the visual versions of these breakdowns, wrong vs right with the RFC receipts, over on Instagram as @thesharpfuture. This week's carousel is this article in nine slides.

Top comments (0)