Course outline

FastAPI / Basic: HTTP and typed boundaries / Lesson 5

Errors and validation boundaries

Distinguish malformed requests, expected domain failures, and programming defects while keeping public errors stable and safe.

About 5 min readContent reviewed
On this page

Lesson goals

  • Explain the assumptions and boundaries of errors and validation boundaries.
  • Interpret both complete examples and justify their observed outputs.
  • Diagnose the listed failures and apply the worked solutions while keeping identity, state, and lifecycle ownership explicit.

Classify before choosing an HTTP response

A bad request shape, an absent resource, a denied operation, a conflict, and an unexpected programming error are different outcomes. FastAPI request validation normally reports 422 for declared invalid input; a valid identifier that does not exist can produce 404 under the application contract. HTTPException is a way to stop request processing with an intended HTTP error, not a general replacement for every Python exception. The first example separates a missing item from a noninteger path. Avoid catching Exception around the entire handler and returning a success-shaped empty object, which hides real defects and misleads callers.

python Complete absence and type-error check
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
app = FastAPI()
@app.get("/items/{item_id}")
def item(item_id: int):
    if item_id != 1:
        raise HTTPException(status_code=404, detail="Item not found")
    return {"id": 1}
with TestClient(app) as client:
    missing = client.get("/items/2")
    invalid = client.get("/items/not-an-integer")
    print(missing.status_code, missing.json())
    print(invalid.status_code, invalid.json()["detail"][0]["loc"])
Expected output
404 {'detail': 'Item not found'}
422 ['path', 'item_id']

A valid but absent ID produces the chosen 404 contract. Invalid path syntax produces 422 at the path location before resource lookup.

Raise expected failures where they are known

A helper or dependency can raise an intended HTTP error and prevent the endpoint from continuing into a success branch. Keep transport decisions at an appropriate application boundary so domain logic remains testable without importing HTTP everywhere. Use stable machine-readable error codes when clients need them, with a message that suggests correction without exposing paths, credentials, or database internals. A failure after a transaction commits needs a different recovery analysis from a failure before validation. An error response does not prove that all previous side effects were reversed or that a client retry is safe.

Customize validation without exposing input

RequestValidationError contains details useful for diagnosis, but raw errors can include submitted values and context that should not be logged or returned indiscriminately. The second example returns a stable code and field locations only. That is an intentional public contract change, so schema, clients, and tests should reflect it. Do not convert every failure into 400 or 200 merely to simplify a frontend branch. Keep request errors separate from response-validation failures, which usually indicate the server violated its own output contract. Review how the pinned dependency versions encode locations and error types.

python Complete sanitized validation response
from typing import Annotated
from fastapi import FastAPI, Query
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
app = FastAPI()
@app.exception_handler(RequestValidationError)
async def invalid_request(request, error):
    fields = [list(item["loc"]) for item in error.errors()]
    return JSONResponse(status_code=422, content={"error": "invalid_request", "fields": fields})
@app.get("/count")
def count(quantity: Annotated[int, Query(gt=0)]): return {"quantity": quantity}
with TestClient(app) as client:
    response = client.get("/count?quantity=0")
    print(response.status_code, response.json())
Expected output
422 {'error': 'invalid_request', 'fields': [['query', 'quantity']]}

Only a stable error code and field location are exposed. The rejected input and framework context are not copied into the public document.

Preserve internal evidence at a controlled boundary

Unexpected errors should have enough approved diagnostic context to investigate under a request or trace identifier while clients receive an appropriate generic response. Preserve an underlying cause when wrapping exceptions and avoid duplicate noisy logs at every layer. TestClient normally raises server exceptions so tests can see programming failures; a dedicated test can disable that behavior when verifying the public 500 boundary. Once a streaming response has started, a handler cannot generally replace it with a clean new error document. Failure ownership therefore includes transaction, response, and cleanup timing rather than just selecting a status number.

Test failure paths as contracts

Cover invalid input, expected absence, authorization denial, domain conflict, unexpected failure, and cleanup. Assert the public shape and stable classification, then verify that invalid requests did not reach a fake side-effect boundary. Use synthetic fixtures and injected dependencies rather than real services or credentials. Neither example starts a server or connects to storage. A useful error design helps callers act and helps operators investigate without leaking sensitive data. It also states which operations are safe to retry and which require reconciliation instead of treating a timeout or 500 as proof that no work happened.

Common errors and fixes

An error is swallowed into a success response

Why it happens
A broad catch hid domain and programming failures together.
How to fix it
Classify expected errors and preserve unexpected failures.
Prevent it next time
Test public status and side-effect behavior on failure.

Validation logs reveal submitted secrets

Why it happens
Raw error input or the full request body was serialized.
How to fix it
Use safe locations, codes, and approved diagnostic fields.
Prevent it next time
Test redaction with synthetic sensitive-shaped values.

A 500 is treated as proof no write occurred

Why it happens
Response failure and transaction outcome were conflated.
How to fix it
Reconcile uncertain outcomes and define retry safety.
Prevent it next time
Track transaction and response boundaries explicitly.

Check your understanding

1. An integer item ID is accepted but the item is absent. Should this be a type-validation error?

Show worked solution

No. Parsing succeeded, so the domain lookup chooses the documented absence response, such as 404. Keep it separate from 422 for invalid declared input. That distinction lets clients correct the right problem and makes monitoring more meaningful.

2. What should a custom validation handler avoid copying into a public response?

Show worked solution

Avoid raw submitted bodies, credentials, stack traces, and unreviewed error context. Return a stable classification and useful field information under the chosen contract. Keep richer internal evidence access-controlled and redacted, and test both documents independently.

Key takeaways

  • A bad request shape, an absent resource, a denied operation, a conflict, and an unexpected programming error are different outcomes.
  • RequestValidationError contains details useful for diagnosis, but raw errors can include submitted values and context that should not be logged or returned indiscriminately.
  • Cover invalid input, expected absence, authorization denial, domain conflict, unexpected failure, and cleanup.

Official references