What you'll learn: how the capstone turns every exception into one HTTP error body, reads its settings from environment variables and fails early on bad ones, and writes structured JSON logs that carry facts about a request but never its text, its tokens or a secret.
At a glance
Figure 1. Every failure leaves through one door
How to read this: callers parse one shape for every failure, and details that could leak stay on the server.
- Raise a named exception; one handler picks the status code and the body.
- A client never sees a stack trace or its own text echoed back.
- Settings come from environment variables, and a bad value stops the process at start-up.
- Logs record facts (sizes, counts, roles, a trace id), not text. Prefer an allow-list of fields.
Scenario
Composite scenario, not a real company. Lumen Industrial Supply's first internal pilot of the product assistant has two problems. When a sales engineer sends a bad request, the screen shows a Python traceback with a file path. And a support engineer, searching the logs for a slow call, finds a line holding the full customer request and an access token. Both are plumbing, not model problems, and a security reviewer finds them in the first hour.
What you will be able to do
- Map your own exceptions to HTTP statuses and one JSON error body with a trace id.
- Read settings from environment variables and fail on bad values at start-up.
- Write a structured log line with no secret and no request text.
Prerequisites
- FastAPI: an AI-ready service for routes and the app object.
- Async, HTTP clients, timeouts and retries: its gateway has its own 502 and 504 handler; the capstone's table below has no 504.
Core ideas
One exception family, one error body
Spring uses @ExceptionHandler methods in a @ControllerAdvice class; Express uses error middleware with four arguments (err, req, res, next); FastAPI uses @app.exception_handler. The design is the same: the route raises, a handler translates.
| Exception | HTTP status | Code in the body |
|---|---|---|
AuthError |
401 | unauthorized |
ForbiddenError, ApprovalError |
403 | forbidden, approval_required |
NotFoundError |
404 | not_found |
IdempotencyConflict |
409 | idempotency_conflict |
RequestValidationError (FastAPI) |
422 | validation_error |
Starlette HTTPException (unknown route, wrong method) |
its own status, such as 404 or 405 | not_found, method_not_allowed, else http_error |
StructuredOutputError (model reply failed validation) |
502 | llm_invalid_output |
ConfigError, any unexpected exception |
500 | config_error, internal_error |
Verified: capstone/fde-product-discovery-copilot/app/errors.py
class AppError(Exception):
status = 500
code = "internal_error"
def __init__(self, message: str) -> None:
super().__init__(message)
self.message = message
class AuthError(AppError):
status, code = 401, "unauthorized"
class ForbiddenError(AppError):
status, code = 403, "forbidden"
A new error needs two class attributes and no new handler:
Verified: capstone/fde-product-discovery-copilot/app/errors.py
def install_error_handlers(app: FastAPI) -> None:
@app.exception_handler(AppError)
async def _app_error(request: Request, exc: AppError) -> JSONResponse:
return JSONResponse(error_body(exc.code, exc.message, _trace(request)), exc.status)
@app.exception_handler(RequestValidationError)
async def _validation(request: Request, exc: RequestValidationError) -> JSONResponse:
# Say where the problem is, never echo the submitted value (it may hold private text).
fields = ", ".join(".".join(str(p) for p in e["loc"][1:]) or "body" for e in exc.errors())
return JSONResponse(error_body("validation_error", f"Invalid input: {fields}", _trace(request)), 422)
FastAPI's documentation warns that returning a validation error as a string could leak information about your system, so the capstone names the failing field and drops the value. The catch-all handler logs only type(exc).__name__, because a message can hold the data that caused it. Real output for a too-short request:
{"error": {"code": "validation_error", "message": "Invalid input: request", "trace_id": "de9db36bcf4f4ea9"}}
The same id is in the X-Trace-Id header, so a user can quote it.
Configuration from the environment
The Twelve-Factor guide says to store config in environment variables, with "strict separation of config from code".
Verified: capstone/fde-product-discovery-copilot/app/config.py
e = os.environ if env is None else env
d = cls()
return cls(
llm_provider=e.get("LLM_PROVIDER", d.llm_provider).lower(),
anthropic_api_key=e.get("ANTHROPIC_API_KEY") or None,
anthropic_model=e.get("ANTHROPIC_MODEL") or None,
dev_secret=e.get("DEV_SECRET", d.dev_secret),
allow_dev_tokens=e.get("ALLOW_DEV_TOKENS", "true").lower() == "true",
token_ttl_s=int(e.get("TOKEN_TTL_S", d.token_ttl_s)),
Three limits: