Skip to content

    Forward Deployed Engineer

    Errors, Configuration and Logging in a FastAPI Service

    Map exceptions to one HTTP error body, read settings from environment variables, and write structured logs that never carry secrets or customer text.

    By Published 6 min read

    Share

    This lesson is part of Forward Deployed Engineer: Python and FastAPI Foundations. Enroll for free, then sign in, to read it in full.

    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

    flowchart LR accTitle: How exceptions become one error body accDescr: A route raises an application error, or FastAPI raises a validation error, or an HTTP error such as 404, or something unexpected goes wrong. Four registered handlers each build the same JSON body with a code, a message and a trace id. The unexpected case also logs only the exception type, never its message. R["Route code or FastAPI"] --> A["AppError subclass"] R --> V["Validation error"] R --> H["HTTP error, for example 404"] R --> U["Unexpected exception"] A --> B["One body: code, message, trace_id"] V --> B H --> B U --> B U --> L["Log the exception type only"]

    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

    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:

    Behaviour What happens Better
    ALLOW_DEV_TOKENS=1 counts as off, because only the text true counts as on accept a fixed list, fail on anything else
    TOKEN_TTL_S=abc int() raises ValueError at start-up, but the message repeats the bad value name the variable without echoing the value, as SafeSettings does
    repr(Settings(...)) prints the API key and the dev secret, because a dataclass lists every field field(repr=False) on secrets

    Logs: facts, not text

    Verified: capstone/fde-product-discovery-copilot/app/observability/logging.py

    def safe_fields(fields: dict[str, Any]) -> dict[str, Any]:
        """Drop any field whose name could carry a secret or private text."""
        return {k: v for k, v in fields.items() if k.lower() not in _FORBIDDEN}
    
    
    def log_event(event: str, **fields: Any) -> None:
        logging.getLogger(LOGGER_NAME).info(event, extra={"fields": safe_fields(fields)})

    log_event passes fields through the extra argument, which Python's logging documentation says populates the log record with user-defined attributes; JsonFormatter writes one JSON object per line. A real line from one discovery call (the cost uses placeholder prices):

    {"confidence": "high", "cost_eur": 0.0042, "event": "discovery", "input_tokens": 2705, "level": "info", "output_tokens": 299, "recommendations": 2, "refused": false, "request_chars": 77, "role": "public", "tenant": "acme", "trace_id": "443dd5875d134ee0", "user_id": "pat-public", "workflow_status": "completed", "workflow_steps": 6}

    The line says how long the request was, not what it said. OWASP says this data should usually not be recorded directly (remove, mask, sanitize, hash or encrypt it):

    • access tokens, authentication passwords, encryption keys and other primary secrets
    • session identifiers, database connection strings and sensitive personal data

    The capstone's safe_fields is a deny-list: it drops fields by name. An allow-list flips the default.

    Deny-list (the capstone) Allow-list
    A field is written when its name is not on the list its name is on the list
    A new field called prompt written dropped until someone approves it

    Spot the flaw. Find every leak in this pull request.

    Pseudocode.

    log.info("discovery request: %s", body.request)
    log.info("settings: %r", settings)
    log.info("incoming headers: %s", dict(request.headers))
    Answer

    All three leak: the customer's text, every settings field including the API key and signing secret, and the Authorization header that lets a log reader replay the token. Log the request length, the role and the trace id instead.

    At the customer: a security reviewer asks, "What is in your logs?" A good answer lists the fields, says text and tokens are never logged, names who can read the logs and for how long, and points at the test.

    Worked example

    Lumen's assistant gets a too-short request, one with no token, then one that triggers a bug.

    Step What the caller sends Status Body code Written to the log
    1 request text of 2 characters 422 validation_error nothing about the text
    2 no Authorization header 401 unauthorized nothing
    3 a bug throws KeyError deep in the pipeline 500 internal_error unhandled error: KeyError

    Assumptions: the capstone's error shape; one trace id per request; step 3 is forced in the tests.

    Build it

    From capstone/fde-product-discovery-copilot, run:

    ./.venv/Scripts/python -m pytest lessons/M1-L5

    This test proves the deny-list limit above, so read it as a warning:

    Verified: capstone/fde-product-discovery-copilot/lessons/M1-L5/test_m1_l5_errors_config_logging.py

    def test_a_deny_list_misses_a_field_nobody_thought_of() -> None:
        """The capstone drops fields by NAME. A private value under another name gets through."""
        leaked = safe_fields({"prompt": "customer wants 40 pumps monitored", "customer_phone": "+49 30 1234", "role": "public"})
        assert "prompt" in leaked and "customer_phone" in leaked

    The folder also holds SafeSettings (secrets hidden from repr, a start-up check via check_ready()) and allow_listed().

    WARNING

    dev_secret has a default so tests run offline. A real deployment must refuse to start with it.

    Exercise

    Add RateLimitedError: status 429, code rate_limited, and a Retry-After header (the previous lesson's retry code honours it).

    Acceptance criteria:

    • The class extends AppError and carries a retry_after_s number.
    • The body uses the same error shape; the header holds the seconds.
    • Other AppError subclasses keep the generic handler.

    Done when two tests pass: the 429 response, and a 401 with no Retry-After header.

    Hint 1: where does the handler go?

    Register a second handler with @app.exception_handler(RateLimitedError). Starlette picks the handler for the most specific class.

    Hint 2: how do you build the response?

    Use error_body(code, message, trace_id) from app.errors and pass the header through headers= of JSONResponse. The reference solution is lessons/M1-L5/m1_l5_rate_limit.py.

    Common mistakes

    • Returning str(exc) to the client. It can hold paths, SQL or user data. Return a fixed message and a trace id.
    • Echoing the invalid value in a validation error. The submitted text may be the private part.
    • Logging the whole request or headers "for debugging". Log sizes and ids instead.
    • Printing a settings object. A dataclass repr shows every field.

    Knowledge check

    1. Why does one error body for every failure help callers?

    A caller writes one parser and one retry rule. code is for programs, message for people, trace_id links to one log line.

    2. The catch-all handler logs only `type(exc).__name__`. What does that protect and cost?

    It keeps out the message, which may contain the data that caused the failure. It costs some detail, which the trace id, the type and a test recover.

    3. `ALLOW_DEV_TOKENS=1` silently disables dev tokens. Is that a security problem?

    It fails safe, because the feature turns off. It is still a bug: the operator meant "on" and got "off" silently.

    4. Why is an allow-list of log fields safer than a deny-list?

    A deny-list covers only names someone already thought of, so a new prompt field gets written. An allow-list writes nothing until a field is approved.

    5. A teammate wants to log request text to debug recommendations. What do you propose?

    Log the trace id, the request length and the outcome. If the text is truly needed, store it apart with access control and a retention period.

    You can now

    • Map exceptions to statuses and one error body.
    • Read settings from the environment and hide secrets from repr.
    • Write a structured log line without secrets or request text.
    • Explain deny-list versus allow-list logging.

    Further reading

    Last verified: 2026-11-02

    Next

    Testing with pytest

    Enroll to keep reading

    Lesson 8 of 9 · Forward Deployed Engineer: Python and FastAPI Foundations

    Map exceptions to one HTTP error body, read settings from environment variables, and write structured logs that never carry secrets or customer text.

    In the rest of this lesson

    • Worked example
    • Build it
    • Exercise
    • Common mistakes
    • Knowledge check
    • You can now

    Enrolling is free. It unlocks every lesson in Forward Deployed Engineer: Python and FastAPI Foundations, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments