Skip to course content
Free FastAPI backend course

FastAPI for AI Backend Development

Unit 12.01: Failures at the boundary you do not control

A dependency failing is not a bug in your code and must not be a 500.

Boundary failures map to specific statuses

Two boundary exceptions and the statuses they should become.

The code shows the mapping.

class DatabaseUnavailable(Exception): pass
class UpstreamTimeout(Exception): pass


def call_boundary(kind: str) -> dict:
    if kind == "db_down":
        raise DatabaseUnavailable("connection refused")
    if kind == "slow":
        raise UpstreamTimeout("no response in 2s")
    return {"ok": True}


MAPPING = {
    "DatabaseUnavailable": (503, "service_unavailable", "retry with backoff"),
    "UpstreamTimeout":     (504, "upstream_timeout",    "retry once"),
    "ValueError":          (422, "invalid_input",       "do not retry"),
}
for kind in ("ok", "db_down", "slow"):
    try:
        print(f"{kind:8} -> 200 {call_boundary(kind)}")
    except Exception as exc:
        status, code, advice = MAPPING[type(exc).__name__]
        print(f"{kind:8} -> {status} {{'error': '{code}'}}  client should: {advice}")

print("\nA boundary failure is not a bug in your code and must not be a 500.")
print("500 tells the client 'our fault, unknown'; 503 tells them 'retry'.")

500 tells the caller "our fault, unknown, we do not know if retrying helps". 503 tells them "temporarily unavailable, retry with backoff", which is actionable.

The difference matters because a well-behaved client behaves differently: it backs off and retries a 503, and it alerts a human on a 500.

The mistake this prevents

The mistake is letting boundary exceptions propagate as unhandled errors. Every database blip then reads as an application bug in your monitoring, and the genuine bugs are buried among them.

Takeaway

Map boundary failures to 503 or 504 rather than letting them become 500s. The status is what tells a client whether retrying can help.