DEV Community

Sir Max
Sir Max

Posted on

Stop Returning Bare 500s: A Practical Guide to RFC 9457 Problem Details

Stop Returning Bare 500s: A Practical Guide to RFC 9457 Problem Details

A few months ago I was integrating with a third-party payments API. Everything worked in their sandbox. The moment I flipped to production, a call returned HTTP 200 with this body:

{ "success": false, "msg": "error" }
Enter fullscreen mode Exit fullscreen mode

No status code, no error type, no field hint. Just "error". It took me and their support team a full day to discover the real cause: my request had a transaction amount with one extra decimal place than their schema allowed.

That day cost me more than any bug I've written myself. And it wasn't a hard problem to prevent — the API just didn't tell me what was wrong in a way a machine (or a tired human) could act on.

Here's the thing: error responses are part of your API's contract, not an afterthought. If your happy path is well documented but your error path is a black box, integrators will burn hours, then quietly move to a competitor.

The problem with "just return a message"

Most APIs fall into one of three failure modes:

  1. The bare 500. A generic server error with an empty body or an HTML error page. The client learns nothing.
  2. The 200-that-isn't. Everything returns 200, and the real status is buried in a payload like { "code": 500 }. This breaks caching, retries, and every HTTP-aware tool in the chain.
  3. The inconsistent shape. Sometimes {"error": "..."}, sometimes {"message": "..."}, sometimes {"detail": "..."}. Clients end up writing defensive parsers for all of them.

All three share the same root cause: there's no agreed shape for an error, so every endpoint invents its own.

Enter RFC 9457: Problem Details

RFC 9457 (the successor to the older RFC 7807) defines a standard JSON format for HTTP error responses. The magic is its simplicity — a problem detail looks like this:

{
  "type": "https://api.example.com/problems/insufficient-funds",
  "title": "Insufficient funds",
  "status": 402,
  "detail": "The account does not have enough balance to cover this transaction.",
  "instance": "/transactions/8f2a1c/attempts/41",
  "balance": 12.50,
  "required": 19.99
}
Enter fullscreen mode Exit fullscreen mode

The fields:

  • type — a URI identifying the kind of problem. Machine-readable, stable over time.
  • title — a short, human-readable summary (should not change between occurrences of the same type).
  • status — the HTTP status code, duplicated for convenience.
  • detail — a human-readable explanation specific to this occurrence.
  • instance — a URI pointing to the specific resource that caused the problem.

Crucially, you can add extension members — like balance and required above — to carry domain-specific data. That's where the real power lives.

The response must use the Content-Type: application/problem+json header, which tells clients "this is a structured error, parse it accordingly."

A working example in FastAPI

Here's the naive way most Python services handle errors today:

from fastapi import FastAPI, HTTPException

app = FastAPI()

@app.post("/orders")
def create_order(order: dict):
    if order.get("quantity", 0) <= 0:
        raise HTTPException(status_code=400, detail="bad quantity")
    ...
Enter fullscreen mode Exit fullscreen mode

The client gets {"detail": "bad quantity"}. That's fine for a human, but a client that wants to react programmatically has to string-match "bad quantity" — fragile and impossible to localize.

Let's replace it with Problem Details. First, a small exception class and a handler:

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

class Problem(Exception):
    def __init__(self, type_uri, title, status, detail, **extensions):
        self.type_uri = type_uri
        self.title = title
        self.status = status
        self.detail = detail
        self.extensions = extensions

app = FastAPI()

@app.exception_handler(Problem)
async def problem_handler(request: Request, exc: Problem):
    body = {
        "type": exc.type_uri,
        "title": exc.title,
        "status": exc.status,
        "detail": exc.detail,
    }
    body.update(exc.extensions)
    return JSONResponse(
        status_code=exc.status,
        content=body,
        headers={"Content-Type": "application/problem+json"},
    )
Enter fullscreen mode Exit fullscreen mode

Now the endpoint can raise precise, typed errors:

@app.post("/orders")
def create_order(order: dict):
    qty = order.get("quantity", 0)
    if qty <= 0:
        raise Problem(
            type_uri="https://api.example.com/problems/invalid-quantity",
            title="Invalid quantity",
            status=422,
            detail="quantity must be a positive integer",
            field="quantity",
            received=qty,
        )
Enter fullscreen mode Exit fullscreen mode

And a client gets:

{
  "type": "https://api.example.com/problems/invalid-quantity",
  "title": "Invalid quantity",
  "status": 422,
  "detail": "quantity must be a positive integer",
  "field": "quantity",
  "received": 0
}
Enter fullscreen mode Exit fullscreen mode

The field and received extensions let a frontend highlight the exact input box without parsing prose.

Add a trace id for supportability

One extension I add to every error is a correlation id, so a support ticket can be traced back to a log line:

import uuid

@app.exception_handler(Problem)
async def problem_handler(request: Request, exc: Problem):
    body = {
        "type": exc.type_uri,
        "title": exc.title,
        "status": exc.status,
        "detail": exc.detail,
        "trace_id": request.state.trace_id,  # set by middleware
    }
    body.update(exc.extensions)
    return JSONResponse(
        status_code=exc.status,
        content=body,
        headers={"Content-Type": "application/problem+json"},
    )
Enter fullscreen mode Exit fullscreen mode

When a user reports "it failed again," they can paste the trace_id and you jump straight to the logs. This single habit has cut our debugging time on integration issues by more than half.

Four pitfalls that still bite people

1. Leaking internals. A 500 that includes your stack trace, SQL query, or framework version is a gift to attackers. Log the internals, but return a generic problem detail for unexpected errors:

@app.exception_handler(Exception)
async def unhandled_handler(request: Request, exc: Exception):
    logger.exception("unhandled error", extra={"trace_id": request.state.trace_id})
    return JSONResponse(
        status_code=500,
        content={
            "type": "https://api.example.com/problems/internal-error",
            "title": "Internal server error",
            "status": 500,
            "detail": "Something went wrong. Reference this id with support.",
            "trace_id": request.state.trace_id,
        },
        headers={"Content-Type": "application/problem+json"},
    )
Enter fullscreen mode Exit fullscreen mode

2. Returning 200 for errors. This breaks retries (a client can't tell transient from permanent), breaks caches, and confuses every monitoring tool you own. Use real status codes — that's what they're for.

3. Renaming your type URIs. The type field is your machine-readable contract. Once integrators code against invalid-quantity, renaming it is a breaking change. Treat it like an API version — version the URI (/problems/v2/invalid-quantity) if you must change its meaning.

4. Skipping the Content-Type header. Without application/problem+json, generic clients treat your body as plain JSON and lose the semantic "this is an error" signal. Some HTTP libraries will also fail to parse the body into a typed object.

The payoff

Standardizing on Problem Details didn't make our APIs slower or harder to build — it made them predictable. Integrators stopped pinging me with "what does this error mean?" because the answer was in the response. Support tickets started including a trace_id instead of a screenshot of a red toast.

If you only take one thing from this: write your error responses with the same care you write your success responses. Your integrators are your users too.


Have you hit an API with infuriatingly opaque errors? I'd love to hear the worst one in the comments.

Top comments (0)