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
| Status | When |
|---|---|
| 201 | Success |
| 409 | Duplicate email |
| 422 | Email malformed, password short/long/common/whitespace |
| 429 | Rate limit (3/min) |
| 500 | DB error |
POST /auth/login
| Status | When |
|---|---|
| 200 | Success |
| 401 | Incorrect credentials (the user doesn't exist OR the password is wrong) |
| 422 | Form-data malformed (missing username or password) |
| 429 | Rate limit (5/min) |
| 500 | DB error |
POST /auth/refresh
| Status | When |
|---|---|
| 200 | Success + new tokens |
| 401 | Refresh token invalid/expired/revoked/wrong type |
| 422 | Body malformed |
| 429 | Rate limit (20/min) |
| 500 | Redis error |
POST /auth/logout
| Status | When |
|---|---|
| 204 | Success (even if the token is already expired or malformed) |
| 401 | No token |
GET /users/me
| Status | When |
|---|---|
| 200 | Success |
| 400 | Inactive user (is_active = False) |
| 401 | No token / invalid token / expired / revoked |
POST /posts
| Status | When |
|---|---|
| 201 | Success |
| 401 | No token / invalid |
| 403 | No posts:create permission (user role) |
| 422 | Invalid title/content |
PATCH /posts/{id}
| Status | When |
|---|---|
| 200 | Success |
| 401 | No token |
| 403 | Not the owner + no posts:edit:any |
| 404 | The post doesn't exist |
| 422 | Validation error |
DELETE /posts/{id}
| Status | When |
|---|---|
| 204 | Success |
| 401 | No token |
| 403 | Not the owner + no posts:delete:any |
| 404 | The post doesn't exist |
GET /admin/users
| Status | When |
|---|---|
| 200 | Success |
| 401 | No token |
| 403 | No admin role |
POST /admin/users/{id}/roles
| Status | When |
|---|---|
| 204 | Success |
| 400 | (none — no self-protection on this endpoint) |
| 401 | No token |
| 403 | No admin role |
| 404 | The user doesn't exist OR the role doesn't exist |
POST /api-keys
| Status | When |
|---|---|
| 201 | Success + plaintext key |
| 401 | No token |
| 422 | Empty name |
GET /integrations/posts
| Status | When |
|---|---|
| 200 | Success |
| 401 | No 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
- HTTP Status Codes (MDN) — Complete reference
- REST API Error Codes Best Practices — Naming guide
- JSON:API Errors — Error shape spec
- FastAPI — Handling Errors — Official docs
- 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.