Skip to content

Configuration and logging

  • Lesson 17
  • 45 min
  • Module 6: Test and harden the service

What you will be able to do

When a user reports “I got an error”, find that exact request in your logs in seconds. And run the same code with different settings: on your computer, in tests, and on the server, with no change to the code.

Before you startWhat to know and to have readyAbout 45 minutes

What you need to know

What you need on your computer

Tested with Python 3.14.6 and FastAPI 0.142.2 on macOS.

The API reads three environment variables in main.py now (ALLOWED_ORIGINS, SHOW_DOCS), in two different places, and the classifier is fixed in the code. As in the Python course, settings belong in one module, with safe defaults:

ticket_api/config.py
import os
from dataclasses import dataclass
@dataclass(frozen=True)
class Settings:
allowed_origins: list[str]
classifier_mode: str
log_level: str
show_docs: bool
def load_settings() -> Settings:
"""Read the settings from environment variables, with safe defaults."""
origins = os.environ.get("ALLOWED_ORIGINS", "")
return Settings(
allowed_origins=[o.strip() for o in origins.split(",") if o.strip()],
classifier_mode=os.environ.get("CLASSIFIER_MODE", "keywords"),
log_level=os.environ.get("LOG_LEVEL", "INFO").upper(),
show_docs=os.environ.get("SHOW_DOCS", "true").lower() == "true",
)
Variable Default Meaning
ALLOWED_ORIGINS none Web pages that may call the API from a browser (Module 3).
CLASSIFIER_MODE keywords keywords is the real classifier. unavailable simulates one that is down.
LOG_LEVEL INFO DEBUG, INFO, WARNING or ERROR.
SHOW_DOCS true false turns off /docs, /redoc and /openapi.json (Module 4).

CLASSIFIER_MODE=unavailable lets you see the API’s behaviour when its dependency is down, on your computer and, in Module 7, on the server. It uses a small classifier that always fails, at the end of classifier.py:

ticket_api/classifier.py (add at the end)
class UnavailableClassifier:
"""Simulates a classifier that is down."""
version = "unavailable"
def predict(self, text: str) -> Prediction:
raise ClassifierUnavailable("the classifier is not available")
def make_classifier(mode: str) -> KeywordClassifier | UnavailableClassifier:
classifiers = {"keywords": KeywordClassifier, "unavailable": UnavailableClassifier}
if mode not in classifiers:
raise ValueError(f"unknown CLASSIFIER_MODE {mode!r}")
return classifiers[mode]()

An unknown mode stops the application at start, with a clear message. That is better than starting with a wrong classifier.

Until now, main.py made the application when Python imported it, with the settings of that moment. A test that wants different settings, such as classifier_mode="unavailable", cannot change them. The fix is a factory: a function that makes a new application from settings that you pass in.

ticket_api/main.py (the structure)
def create_app(settings: Settings | None = None) -> FastAPI:
settings = settings or load_settings()
# ... make the app with these settings, add middleware, handlers and routes
return app
app = create_app()

fastapi dev ticket_api/main.py still finds app, made from the environment variables. Tests call create_app(...) with their own settings, and get a new application each time.

When a user reports an error, the logs must show exactly that request. The API gives each request a short random ID. It puts the ID in the response header X-Request-ID and in every error body, and writes it in its log line. The user copies the ID from the error, and you search the logs for it.

A middleware runs around every request, as the CORS middleware does. Here is the whole new main.py:

ticket_api/main.py
import logging
import time
import uuid
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from ticket_api.classifier import make_classifier
from ticket_api.config import Settings, load_settings
from ticket_api.errors import add_error_handlers
from ticket_api.models import Health
from ticket_api.routes import router
logger = logging.getLogger("ticket_api")
DESCRIPTION = """
Classifies support tickets into a category and a priority.
The classifier is a deterministic mock: it uses keywords, so the same text
always gives the same result. Every error has the same shape:
`{"error": {"code": ..., "message": ..., "request_id": ..., "fields": [...]}}`.
"""
def create_app(settings: Settings | None = None) -> FastAPI:
settings = settings or load_settings()
logging.basicConfig(
level=settings.log_level, format="%(levelname)s %(name)s: %(message)s"
)
app = FastAPI(
title="Ticket Classifier API",
version="1.0.0",
description=DESCRIPTION,
openapi_tags=[
{"name": "tickets", "description": "Classify support tickets."},
{"name": "health", "description": "Check that the service runs."},
],
docs_url="/docs" if settings.show_docs else None,
redoc_url="/redoc" if settings.show_docs else None,
openapi_url="/openapi.json" if settings.show_docs else None,
)
app.state.settings = settings
app.state.classifier = make_classifier(settings.classifier_mode)
@app.middleware("http")
async def request_id_and_log(request: Request, call_next):
request.state.request_id = uuid.uuid4().hex[:12]
started = time.perf_counter()
response = await call_next(request)
response.headers["X-Request-ID"] = request.state.request_id
logger.info(
"%s %s %d %.0fms id=%s",
request.method,
request.url.path,
response.status_code,
(time.perf_counter() - started) * 1000,
request.state.request_id,
)
return response
if settings.allowed_origins:
app.add_middleware(
CORSMiddleware,
allow_origins=settings.allowed_origins,
allow_methods=["GET", "POST"],
allow_headers=["Content-Type"],
)
add_error_handlers(app)
app.include_router(router)
@app.get("/health", tags=["health"], summary="Check that the service runs")
def health() -> Health:
return Health(status="ok")
return app
app = create_app()
Part Why
uuid.uuid4().hex[:12] A random ID, such as 5f5cb5124f1a. 12 characters are enough to be unique in your logs.
request.state.request_id Keeps the ID with this request, so the error handlers can use it.
await call_next(request) Runs the rest of the application: the route and the handlers. The middleware gets the response back.
The log line Method, path, status, time and ID. Not the body, and not the headers.

The middleware logs only facts about the request, never its content. A ticket body can contain a customer’s personal data, and a header can contain an API key. Logs are kept for a long time and read by many people, so they must not hold either.

The error shape gets one more field, in models.py:

ticket_api/models.py (in ErrorDetail)
request_id: str | None = Field(
default=None, description="Give this ID when you report a problem."
)

In errors.py, error_response gets the request as its first parameter, and reads the ID from it. Each handler passes request:

ticket_api/errors.py (changes)
def error_response(
request: Request, status: int, code: str, message: str, fields=()
) -> JSONResponse:
detail = ErrorDetail(
code=code,
message=message,
request_id=getattr(request.state, "request_id", None),
fields=list(fields),
)
return JSONResponse(
status_code=status, content=ErrorResponse(error=detail).model_dump()
)

Each call changes from error_response(422, ...) to error_response(request, 422, ...). The two log lines in the handlers get the ID too: logger.warning("classifier unavailable: %s id=%s", exc, request.state.request_id) and logger.exception("unexpected error id=%s", request.state.request_id).

A file called conftest.py in tests holds fixtures that every test file can use. It gives each test a new application with test settings, and a way to change one setting:

tests/conftest.py
from dataclasses import replace
import pytest
from fastapi.testclient import TestClient
from ticket_api.config import Settings
from ticket_api.main import create_app
TEST_SETTINGS = Settings(
allowed_origins=[],
classifier_mode="keywords",
log_level="WARNING",
show_docs=True,
)
@pytest.fixture
def make_client():
"""Return a function that makes a test client with changed settings."""
def make(**changes) -> TestClient:
app = create_app(replace(TEST_SETTINGS, **changes))
return TestClient(app, raise_server_exceptions=False)
return make
@pytest.fixture
def client(make_client) -> TestClient:
return make_client()

replace (from dataclasses) copies the frozen settings with some fields changed. make_client returns a function, so a test can ask for any settings that it needs. Most tests just ask for client.

The tests change in three small ways:

  1. Remove client = TestClient(app) and its imports from both test files. Give each test function a client parameter instead: def test_health(client):.

  2. Replace the broken-classifier fixture with the setting, which is simpler:

    tests/test_classify.py
    def test_unavailable_classifier_gives_503(make_client):
    client = make_client(classifier_mode="unavailable")
    response = client.post("/v1/classify", json={"subject": "Refund"})
    assert response.status_code == 503
    assert response.json()["error"]["code"] == "classifier_unavailable"
    assert "Traceback" not in response.text
    def test_every_response_has_a_request_id(client):
    ok = client.get("/health")
    error = client.post("/v1/classify", json={"subject": ""})
    assert len(ok.headers["X-Request-ID"]) == 12
    assert error.json()["error"]["request_id"] == error.headers["X-Request-ID"]
  3. In export_contract.py, import create_app instead of app, and use create_app().openapi().

Amira runs the tests. The contract test fails, and its difference shows the new field: "request_id": ... in the description and in the ErrorDetail schema. A new optional field in an error is not breaking, so she runs python -m ticket_api.export_contract, and all 13 tests pass.

Then she sends an invalid ticket to the running API:

Response body
{"error":{"code":"invalid_request","message":"The request is not valid.","request_id":"5f5cb5124f1a","fields":["subject"]}}
API terminal
INFO ticket_api: POST /v1/classify 422 1ms id=5f5cb5124f1a

The ID in the response and the ID in the log are the same. With CLASSIFIER_MODE=unavailable, the log also says why:

API terminal
WARNING ticket_api: classifier unavailable: the classifier is not available id=073a3ced6d65
INFO ticket_api: POST /v1/classify 503 2ms id=073a3ced6d65

(Uvicorn also writes its own line for each request, starting with INFO: 127.0.0.1:.... Your lines start with INFO ticket_api:.)

  1. Make config.py, add UnavailableClassifier and make_classifier to classifier.py, and replace main.py.
  2. Add request_id to ErrorDetail, and change errors.py.
  3. Make tests/conftest.py, and change the tests and export_contract.py.
  4. Run the tests. Read the contract difference, export the contract, and run them again. All pass.
  5. Send a valid and an invalid request to the running API. Find each request’s ID in the response (header or body) and in the API’s terminal.
  6. Stop the API, and start it with CLASSIFIER_MODE=unavailable ($env:CLASSIFIER_MODE = "unavailable" in PowerShell). Send a ticket. You get 503, and the log has a WARNING with the same ID. Start it again without the variable.
  7. Start it with CLASSIFIER_MODE=magic. It stops at once with ValueError: unknown CLASSIFIER_MODE 'magic'.

A user writes: “Classifying failed at 14:02, the error said request_id 9b1c0e7a44d2”. Write down the exact steps that you would follow to find out what happened, with your current logs. Then name one piece of information that the logs do not give you, and say if adding it would be safe.

Show one answer

Search the API’s logs for 9b1c0e7a44d2. The INFO line gives the method, path, status code and time. If it is a 503, there is a WARNING line with the same ID that says the classifier was not available. If it is a 500, there is an ERROR line with the full traceback.

The logs do not have the ticket’s text, so you cannot repeat the exact request. Logging it is not safe by default: tickets can contain personal data. If you need it, ask the user to send the text, or log only its length or a category, never the content.

Common mistake

A secret in the logs

Symptom: a teammate reads the logs to find a bug, and sees API keys or customers’ messages in them.

Cause: someone added a log line with the whole request, for example logger.info("request %s %s", request.headers, body), to debug a problem, and it stayed. Logs are copied to log services, kept for months, and read by many people. Every secret in them has leaked.

Fix:

  1. Remove the log line. Log only facts: method, path, status, duration, ID.
  2. Treat every key that appeared in the logs as leaked: cancel it, and make a new one.
  3. If you need more detail while you debug, use a DEBUG log line with safe fields only, and remove it after the fix.

Check your understanding

Why does create_app() take settings as a parameter, instead of always calling load_settings()?

Show the answer

So that each caller can decide where the settings come from. The server uses the environment variables (create_app() with no argument). A test passes its own settings, for example classifier_mode="unavailable", without setting environment variables, which would affect other tests. Each test gets a new application, so no state leaks from one test to the next.

Recap

  • Read every setting in config.py, with safe defaults. An unknown value stops the application at start.
  • create_app(settings) makes a new application. The server and the tests each pass their own settings.
  • A middleware gives each request an ID, puts it in the X-Request-ID header and the error body, and logs it.
  • Log facts about requests, never their content, headers or secrets.
  • conftest.py holds shared fixtures, such as a client with changed settings.

Next, you protect the API: an API key, a limit on the body size, and a time limit for the classifier.

Next: Basic protection