Module 6: Complete Auth System Project — Introduction

Capsule 05: Complete error handling

Overview

Complete documentation of ALL the error codes your API can return and under what conditions. It's the reference that developers consuming your API need in order to implement correct handling in their clients.


Status code catalog

200 OK

Successful operation with a response body.

GET /users/me → 200 + user data
GET /posts → 200 + array
POST /auth/login → 200 + tokens

201 Created

Resource created successfully.

POST /auth/register → 201 + UserPublic
POST /posts → 201 + PostPublic
POST /api-keys → 201 + ApiKeyCreated (with the plaintext)

204 No Content

Successful operation with no response body.

DELETE /posts/{id} → 204
DELETE /admin/users/{id} → 204
POST /auth/logout → 204

400 Bad Request

Malformed request or broken business logic.

DELETE /admin/users/{me} → 400 "You can't delete yourself"
DELETE /admin/users/{me}/roles/admin → 400 "You can't remove your own admin role"
GET /users/me + disabled user → 400 "Inactive user"

401 Unauthorized

Authentication problem — no token, invalid token, expired token.

GET /users/me with no Authorization → 401
GET /users/me with Bearer malformed-token → 401 "Could not validate credentials"
GET /users/me with Bearer expired-token → 401 "Token expired"
GET /users/me with Bearer revoked-token → 401 "Token revoked"
POST /auth/login with bad credentials → 401 "Incorrect credentials"
POST /auth/refresh with an invalid/expired refresh → 401

Mandatory header: WWW-Authenticate: Bearer (or a variant).

403 Forbidden

Authorization problem — authenticated but without the permission.

DELETE /admin/users/1 with the user/editor role → 403 "Requires role: admin"
POST /posts with the user role → 403 "Missing permission: posts:create"
DELETE /posts/{others} with the editor role (not the owner) → 403 "You can't delete this post"

Does NOT include the WWW-Authenticate header (it isn't an auth problem).

404 Not Found

The resource doesn't exist.

GET /posts/9999 → 404 "Post not found"
PATCH /posts/9999 → 404 "Post not found"
DELETE /admin/users/9999 → 404 "User not found"
DELETE /api-keys/9999 → 404 "API key not found"

409 Conflict

State conflict.

POST /auth/register with a duplicate email → 409 "Email already registered"

422 Unprocessable Entity

Pydantic validation failed.

POST /auth/register with an invalid email → 422 + per-field detail
POST /auth/register with a password < 8 chars → 422 + "ensure this value has at least 8 characters"
POST /auth/register with a blacklisted password → 422 + "too common"
POST /posts with an empty title → 422
GET /posts?limit=200 → 422 (max 100)

FastAPI's standard response shape:

{
  "detail": [
    {
      "type": "value_error",
      "loc": ["body", "password"],
      "msg": "Value error, This password is too common. ...",
      "input": "password",
      "ctx": {...}
    }
  ]
}

429 Too Many Requests

Rate limit exceeded.

6th request to /login in less than 1 minute → 429
4th request to /register in less than 1 minute → 429

Mandatory header: Retry-After: <seconds>.

500 Internal Server Error

A bug in the server. These should not happen in production.

Common causes (all of them bugs to fix):

  • Unhandled exception (NullPointerException, etc.)
  • Lost DB connection
  • Lost Redis connection
  • Incorrect configuration

Mitigation: logging + monitoring + alerts.


Endpoint × possible errors

POST /auth/register

StatusWhen
201Success
409Duplicate email
422Email malformed, password short/long/common/whitespace
429Rate limit (3/min)
500DB error

POST /auth/login

StatusWhen
200Success
401Incorrect credentials (the user doesn't exist OR the password is wrong)
422Form-data malformed (missing username or password)
429Rate limit (5/min)
500DB error

POST /auth/refresh

StatusWhen
200Success + new tokens
401Refresh token invalid/expired/revoked/wrong type
422Body malformed
429Rate limit (20/min)
500Redis error

POST /auth/logout

StatusWhen
204Success (even if the token is already expired or malformed)
401No token

GET /users/me

StatusWhen
200Success
400Inactive user (is_active = False)
401No token / invalid token / expired / revoked

POST /posts

StatusWhen
201Success
401No token / invalid
403No posts:create permission (user role)
422Invalid title/content

PATCH /posts/{id}

StatusWhen
200Success
401No token
403Not the owner + no posts:edit:any
404The post doesn't exist
422Validation error

DELETE /posts/{id}

StatusWhen
204Success
401No token
403Not the owner + no posts:delete:any
404The post doesn't exist

GET /admin/users

StatusWhen
200Success
401No token
403No admin role

POST /admin/users/{id}/roles

StatusWhen
204Success
400(none — no self-protection on this endpoint)
401No token
403No admin role
404The user doesn't exist OR the role doesn't exist

POST /api-keys

StatusWhen
201Success + plaintext key
401No token
422Empty name

GET /integrations/posts

StatusWhen
200Success
401No X-API-Key / invalid / expired

Error body shapes

401 Unauthorized

{
  "detail": "Could not validate credentials"
}

Headers: WWW-Authenticate: Bearer

401 Token expired

{
  "detail": "Token expired"
}

Headers: WWW-Authenticate: Bearer error="invalid_token"

403 Forbidden

{
  "detail": "Missing permission: posts:create"
}

(In production, consider a generic message: "You don't have permission for this action")

422 Validation Error

{
  "detail": [
    {
      "type": "value_error",
      "loc": ["body", "password"],
      "msg": "Value error, This password is too common...",
      "input": "<input value>"
    }
  ]
}

429 Too Many Requests

{
  "error": "Rate limit exceeded: 5 per 1 minute"
}

Headers: Retry-After: 47


How the client should handle each one

Pseudo-code for a robust client

async function apiCall(method, url, body) {
    const response = await fetch(url, { method, body, headers: {...} });
    
    switch (response.status) {
        case 200:
        case 201:
            return await response.json();
        
        case 204:
            return null;  // success with no body
        
        case 400:
            const data400 = await response.json();
            throw new BusinessError(data400.detail);
        
        case 401:
            // Token issues — try refreshing
            const refreshed = await tryRefresh();
            if (refreshed) {
                return apiCall(method, url, body);  // retry
            }
            redirectToLogin();
            break;
        
        case 403:
            const data403 = await response.json();
            throw new PermissionError(data403.detail);
        
        case 404:
            return null;  // or throw NotFoundError
        
        case 409:
            const data409 = await response.json();
            throw new ConflictError(data409.detail);
        
        case 422:
            const data422 = await response.json();
            throw new ValidationError(data422.detail);
        
        case 429:
            const retryAfter = parseInt(response.headers.get("Retry-After")) || 60;
            await sleep(retryAfter * 1000);
            return apiCall(method, url, body);  // retry afterwards
        
        case 500:
        case 502:
        case 503:
            throw new ServerError("Server error, try again later");
        
        default:
            throw new Error(`Unexpected status: ${response.status}`);
    }
}

Privacy: 404 vs 403

For endpoints where the EXISTENCE of the resource is sensitive info, prefer 404:

@router.get("/posts/{id}")
async def get_post(id: int, ...):
    post = await db.get(Post, id)
    if post is None:
        raise HTTPException(404, "Post not found")
    
    # If the user can't view this post (e.g. it's someone else's draft):
    if not can_view(user, post):
        raise HTTPException(404, "Post not found")  # ← the same response

GitHub does this: if you request /repos/private-repo and you don't have access, you get a 404 (not a 403). It doesn't confirm that the repo exists.

For our public blog: posts are visible to everyone, so this doesn't apply. But if you had private posts, it's worth considering.


Messages in production

Trade-off: specific messages help developers debug, but they can leak information to attackers.

# Helper
def error_message(prod_msg: str, dev_msg: str) -> str:
    return prod_msg if settings.is_production else dev_msg


raise HTTPException(
    403,
    error_message(
        prod_msg="You don't have permission for this action",
        dev_msg=f"Missing permission: posts:delete:any (you have: {list(user.permission_names)})",
    ),
)

In dev: a detailed message, easy to debug. In prod: a generic message, no info leak.


Error logging

In your app you should log errors for debugging:

import logging
from fastapi import Request

logger = logging.getLogger(__name__)


@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
    logger.exception(f"Unhandled exception on {request.url}: {exc}")
    return JSONResponse(
        status_code=500,
        content={"detail": "Internal server error"},
    )

Log it, don't expose the details to the client.

For 4xx:

@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
    logger.warning(f"{exc.status_code} on {request.url}: {exc.detail}")
    return JSONResponse(
        status_code=exc.status_code,
        content={"detail": exc.detail},
        headers=exc.headers,
    )

Expected outputs

Visiting /docs should show ALL the possible responses for each endpoint:

POST /auth/register

Responses:
  201 — UserPublic
  409 — {"detail": "Email already registered"}
  422 — Validation Error (FastAPI default)
  429 — Rate limit exceeded

This is auto-generated by FastAPI if you decorate it correctly:

@router.post(
    "/register",
    response_model=UserPublic,
    status_code=201,
    responses={
        409: {"description": "Email already registered"},
        429: {"description": "Rate limit exceeded"},
    },
)
async def register(...):

Troubleshooting

Problem 1: "My client gets a 401 when it expected a 403"

Check that you're returning HTTPException(403, ...) when it's an authorization problem (not an authentication one).

Problem 2: "My client doesn't understand Pydantic's 422 errors"

A custom exception handler for 422:

@app.exception_handler(RequestValidationError)
async def validation_handler(request, exc):
    return JSONResponse(
        status_code=422,
        content={
            "errors": [
                {"field": ".".join(str(x) for x in err["loc"][1:]), "message": err["msg"]}
                for err in exc.errors()
            ]
        },
    )

A friendlier output:

{
  "errors": [
    {"field": "password", "message": "Value error, too common"},
    {"field": "email", "message": "value is not a valid email"}
  ]
}

Problem 3: "500 errors in production and I don't know what happened"

Set up:

  • Sentry (or similar)
  • Aggressive logging
  • APM (Application Performance Monitoring)

A 500 with NO log = blind to bugs.

Problem 4: "I want a custom code for 'payment required'"

402 Payment Required exists. Rarely used, but valid.


Exercises

Exercise 1: Custom 422 handler

Implement the custom exception handler for 422 that returns the friendly shape.

See solution

The code is in the troubleshooting section.

Exercise 2: Logging configured

Configure logging so that it records every 4xx and 5xx error.

See solution
# app/main.py
import logging
from fastapi import Request, HTTPException
from fastapi.responses import JSONResponse

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)


@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
    logger.warning(
        f"{exc.status_code} {request.method} {request.url.path}: {exc.detail}"
    )
    return JSONResponse(
        status_code=exc.status_code,
        content={"detail": exc.detail},
        headers=exc.headers,
    )


@app.exception_handler(Exception)
async def general_exception_handler(request: Request, exc: Exception):
    logger.exception(f"500 {request.method} {request.url.path}")
    return JSONResponse(
        status_code=500,
        content={"detail": "Internal server error"},
    )

Exercise 3: Document every response in /docs

For each endpoint, add responses= with all the possible codes.

See solution
@router.post(
    "/register",
    response_model=UserPublic,
    status_code=201,
    responses={
        201: {"description": "User created"},
        409: {"description": "Email already registered"},
        422: {"description": "Validation error"},
        429: {"description": "Rate limit exceeded"},
    },
)

Do this for the ~15 main endpoints. Tedious, but useful.

Exercise 4: A test for every error

Implement at least one test for every status code your API can return.

See solution

Minimum list:

test_register_success           # 201
test_register_duplicate         # 409
test_register_validation        # 422
test_register_rate_limit        # 429

test_login_success              # 200
test_login_wrong_password       # 401
test_login_form_invalid         # 422

test_me_no_auth                 # 401
test_me_with_auth               # 200
test_me_inactive_user           # 400

test_create_post_no_perm        # 403
test_create_post_not_found      # 404 (on update/delete with a nonexistent id)

You already have most of them implemented. Confirm they cover every case.

Exercise 5: Dev vs prod message

Implement the error_message(prod_msg, dev_msg) helper and use it in at least 3 places.

See solution
def error_message(prod_msg: str, dev_msg: str) -> str:
    return prod_msg if settings.is_production else dev_msg


# Use it in:
raise HTTPException(
    403,
    error_message(
        "You don't have permission for this action",
        f"Missing permission: {permission_name}",
    ),
)

Summary

  • Every endpoint can return several codes. Document ALL of them for your clients.
  • 2xx for success, 4xx for client errors, 5xx for server errors.
  • 401 vs 403: authn vs authz. The client acts differently for each.
  • 422 is Pydantic validation. A custom handler gives you a friendly shape.
  • 429 with a Retry-After header.
  • 500s are bugs — always log them, never ignore them.
  • Specific messages in dev, generic ones in prod (no info leak).
  • 404 vs 403: a 404 hides existence.
  • Logging is mandatory in production.

Additional resources

  1. HTTP Status Codes (MDN) — Complete reference
  2. REST API Error Codes Best Practices — Naming guide
  3. JSON:API Errors — Error shape spec
  4. FastAPI — Handling Errors — Official docs
  5. Sentry — Error tracking in production

Next step

Capsule 06: Integration E2E tests. You're going to write tests that cover complete flows, not just isolated endpoints.