C.W.K.
Stream
Lesson 07 of 07 · published

Error Envelope Design — Give the Client Something to Work With

~10 min · rest-design, errors, error-envelope, rfc7807

Level 0HTTP Newbie
0 XP0/46 lessons0/12 achievements
0/120 XP to next level120 XP to go0% complete
"The status code says 'something went wrong.' The error body is where you tell the client WHAT went wrong, WHY, and what to do about it. Without a structured body, every error looks the same and your support inbox fills up."

What an Error Body Has to Do

A well-designed error response serves four audiences, often at once:

  • The end user, who sees a UI error message and needs to know what to fix.
  • The integrating developer, who's debugging their client code and needs a machine-readable code to branch on.
  • The on-call engineer at your support desk, who needs a request ID to correlate with server logs.
  • Automated monitoring, which wants to count errors by code and detect outliers.

One status code can't carry all four. The body has to.

The Minimal Envelope

Three fields cover 80% of cases:

{
  "error": {
    "code": "invalid_email_format",
    "message": "The email address 'pippa@@example.com' is not valid.",
    "request_id": "req_xyz789"
  }
}

code is a machine-readable identifier — clients branch on this, never on the human message. message is for humans (and increasingly for AI agents that read API responses). request_id closes the loop between client and server logs.

The Multi-Error Envelope (Validation)

Validation errors are special: one request might have multiple problems (email is malformed AND password is too short AND age is negative). Surfacing them one at a time forces three round trips. Return them all:

{
  "error": {
    "code": "validation_failed",
    "message": "Request validation failed for 3 fields.",
    "errors": [
      {"field": "email",    "code": "invalid_format",     "message": "..."},
      {"field": "password", "code": "too_short",          "message": "...", "min": 8},
      {"field": "age",      "code": "out_of_range",       "message": "...", "min": 0}
    ],
    "request_id": "req_xyz789"
  }
}

FastAPI + Pydantic does this out of the box for 422 responses. Most hand-rolled APIs underspecify this and force clients to display "validation failed" without saying which field.

RFC 7807 — Problem Details for HTTP APIs

The IETF formalized an error envelope in RFC 7807, with media type application/problem+json. Five fields:

{
  "type":     "https://example.com/errors/invalid-email",
  "title":    "Invalid email format",
  "status":   400,
  "detail":   "The email address 'pippa@@example.com' is not valid.",
  "instance": "/users/42"
}

type is a stable URI identifying the error class (think of it as a stable code with documentation behind it). title is a short human summary. status mirrors the HTTP status. detail is the specific message. instance is the specific resource that errored.

RFC 7807 is adopted by some APIs (Microsoft, Spring Boot) and ignored by most. Both stances work. The principle — structured error body with stable identifiers — matters more than the specific format.

Clients branch on codes; humans read messages. Never write a client that does if error_message == "User not found": ... — that string can change for usability reasons, and now your client breaks. Always branch on the stable machine code. The two fields exist precisely because they serve different audiences.

The Anti-Patterns That Hurt

Five patterns that show up in real APIs and shouldn't:

  • 200 OK with {error: ...} — Caches treat it as success. Retry logic doesn't fire. Monitoring dashboards show green when everything is broken. Always use 4xx or 5xx.
  • Plain string body. "User not found" as the entire response. Clients can't branch on it; localizers can't translate it; support can't correlate it.
  • HTML stack traces in production. Leaks server internals (file paths, framework versions, environment variables). Reserve for development; in production, return a generic message + request_id for the on-call to look up.
  • Inconsistent shape across endpoints. /users returns {error: ...}; /orders returns {message: ...}; /payments returns {detail: ...}. Clients give up.
  • No request_id. When a user reports an error, support has no way to find the corresponding server log. Always include a request ID, even for successful responses.

cwkPippa's Error Envelope

cwkPippa uses FastAPI's default error envelope ({detail: "..."} for 4xx/5xx, validation-array for 422 Pydantic errors). It's inconsistent with what I'd design from scratch — single-field detail doesn't give clients machine-readable codes, and there's no request_id. But the frontend is the only client and reads detail for toast messages, so the cost hasn't bitten yet. When the day comes (third-party client, support escalation), the migration is to wrap everything in a custom exception handler with a structured envelope.

Code

FastAPI: custom envelope with code, message, request_id, validation errors·python
# FastAPI — custom exception handler with structured envelope
from fastapi import FastAPI, Request, HTTPException, status
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
import uuid

app = FastAPI()

class APIError(Exception):
    def __init__(self, status_code: int, code: str, message: str, **extras):
        self.status_code = status_code
        self.code = code
        self.message = message
        self.extras = extras

@app.exception_handler(APIError)
async def api_error_handler(request: Request, exc: APIError):
    request_id = request.headers.get('x-request-id', str(uuid.uuid4()))
    return JSONResponse(
        status_code=exc.status_code,
        content={
            'error': {
                'code': exc.code,
                'message': exc.message,
                'request_id': request_id,
                **exc.extras,
            }
        },
        headers={'X-Request-ID': request_id},
    )

@app.exception_handler(RequestValidationError)
async def validation_handler(request: Request, exc: RequestValidationError):
    request_id = request.headers.get('x-request-id', str(uuid.uuid4()))
    errors = [
        {
            'field': '.'.join(str(p) for p in e['loc'][1:]),
            'code': e['type'],
            'message': e['msg'],
        }
        for e in exc.errors()
    ]
    return JSONResponse(
        status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
        content={
            'error': {
                'code': 'validation_failed',
                'message': f'Request validation failed for {len(errors)} field(s).',
                'errors': errors,
                'request_id': request_id,
            }
        },
        headers={'X-Request-ID': request_id},
    )

# Now in your handlers:
@app.get('/users/{uid}')
async def read_user(uid: str):
    user = await db_find(uid)
    if user is None:
        raise APIError(
            status_code=404,
            code='user_not_found',
            message=f'No user with id {uid}.',
            user_id=uid,
        )
    return user
Client: machine code drives branches; message displayed to users·python
# Client — branch on code, never on message
import httpx

def call(method: str, url: str, **kwargs):
    resp = httpx.request(method, url, **kwargs)
    if resp.is_error:
        body = resp.json().get('error', {})
        code = body.get('code')
        if code == 'user_not_found':
            return None  # treat as missing
        if code == 'validation_failed':
            errors_by_field = {e['field']: e for e in body.get('errors', [])}
            raise UserInputError(errors_by_field)
        if code == 'rate_limited':
            wait = int(resp.headers.get('Retry-After', 1))
            time.sleep(wait)
            return call(method, url, **kwargs)
        # Unknown error — log request_id for support escalation
        raise UnknownAPIError(
            code or f'http_{resp.status_code}',
            body.get('message', resp.text),
            request_id=body.get('request_id'),
        )
    return resp.json()
RFC 7807 Problem Details — if you prefer the IETF standard shape·json
// RFC 7807 Problem Details — for APIs that prefer the standard
// Content-Type: application/problem+json

// Single-error problem
{
  "type":     "https://api.example.com/errors/user-not-found",
  "title":    "User not found",
  "status":   404,
  "detail":   "No user with id u_42 exists.",
  "instance": "/users/u_42"
}

// Validation problem with per-field detail (extension members)
{
  "type":     "https://api.example.com/errors/validation-failed",
  "title":    "Request validation failed",
  "status":   422,
  "detail":   "Validation failed for 3 fields.",
  "instance": "/users",
  "errors":   [
    {"field": "email",    "code": "invalid_format"},
    {"field": "password", "code": "too_short", "min": 8},
    {"field": "age",      "code": "out_of_range", "min": 0}
  ]
}

External links

Exercise

Design an error envelope for the FastAPI app you've been building. Pick either the custom-envelope pattern (code/message/request_id) or RFC 7807 — but pick one and apply it consistently to every error response. Implement a custom exception handler that wraps validation errors into per-field arrays. Then write a client wrapper that branches on error.code (never on message) and logs the request_id for any unknown error. Bonus: deliberately trigger five different errors (404, 422, 401, 429, 500) and verify each one produces a parseable, useful envelope.
Hint
FastAPI's app.exception_handler decorator lets you customize every error shape. Make sure your handler ALSO covers HTTPException, RequestValidationError, and any custom exception class. The request_id should be generated at the middleware layer so EVERY request has one, even successful ones — store it in request.state.request_id and log it. The five-error test is the integration check: each one should respect the envelope shape, and your client should handle each one correctly without crashing.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.