Configuration and logging
- Lesson 17
- 45 min
- Module 6: Test and harden the service
Lesson 17 of 21 · Build and Deploy Your First APIConfiguration and logging
1. Understand the web request
2. Build a small FastAPI service
3. Design a usable contract
4. Document the API with OpenAPI and Swagger UI
5. Explore and test with Postman
6. Test and harden the service
7. Deploy and verify
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 ready
What you need to know
- Environment variables and a settings module, from Configuration and secrets: Worked example: a settings module.
- Logging, from Exceptions and logging: Logging instead of print.
What you need on your computer
tests/test_classify.py,ClassifierUnavailableand the503handler, from Automated endpoint tests: Guided practice: test every path.python -m pytestpasses.main.pyreadsALLOWED_ORIGINS, from Browser access and CORS: Worked example: allow one origin, andSHOW_DOCS, from Keep the documentation true: Decide what to expose in production. This lesson moves both settings intoconfig.py.ticket_api/export_contract.py, from Keep the documentation true: Worked example: the reviewed contract. You change it in this lesson.- If your project is missing or broken, copy the snapshot
end-of-m05, the project at the end of Module 5, as in Use a snapshot. Then do Automated endpoint tests: Guided practice: test every path again.
Tested with Python 3.14.6 and FastAPI 0.142.2 on macOS.
One place for every setting
Section titled “One place for every setting”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:
import osfrom 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:
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.
An application factory
Section titled “An application factory”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.
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.
A request ID for every request
Section titled “A request ID for every request”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:
import loggingimport timeimport uuid
from fastapi import FastAPI, Requestfrom fastapi.middleware.cors import CORSMiddleware
from ticket_api.classifier import make_classifierfrom ticket_api.config import Settings, load_settingsfrom ticket_api.errors import add_error_handlersfrom ticket_api.models import Healthfrom 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 textalways 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 ID in the error body
Section titled “The ID in the error body”The error shape gets one more field, in models.py:
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:
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).
Tests with their own settings
Section titled “Tests with their own settings”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:
from dataclasses import replace
import pytestfrom fastapi.testclient import TestClient
from ticket_api.config import Settingsfrom ticket_api.main import create_app
TEST_SETTINGS = Settings( allowed_origins=[], classifier_mode="keywords", log_level="WARNING", show_docs=True,)
@pytest.fixturedef 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.fixturedef 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:
-
Remove
client = TestClient(app)and its imports from both test files. Give each test function aclientparameter instead:def test_health(client):. -
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 == 503assert response.json()["error"]["code"] == "classifier_unavailable"assert "Traceback" not in response.textdef 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"]) == 12assert error.json()["error"]["request_id"] == error.headers["X-Request-ID"] -
In
export_contract.py, importcreate_appinstead ofapp, and usecreate_app().openapi().
Worked example: follow one request
Section titled “Worked example: follow one request”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:
{"error":{"code":"invalid_request","message":"The request is not valid.","request_id":"5f5cb5124f1a","fields":["subject"]}}INFO ticket_api: POST /v1/classify 422 1ms id=5f5cb5124f1aThe ID in the response and the ID in the log are the same. With CLASSIFIER_MODE=unavailable, the log also says why:
WARNING ticket_api: classifier unavailable: the classifier is not available id=073a3ced6d65INFO 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:.)
Guided practice: settings and request IDs
Section titled “Guided practice: settings and request IDs”- Make
config.py, addUnavailableClassifierandmake_classifiertoclassifier.py, and replacemain.py. - Add
request_idtoErrorDetail, and changeerrors.py. - Make
tests/conftest.py, and change the tests andexport_contract.py. - Run the tests. Read the contract difference, export the contract, and run them again. All pass.
- 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.
- Stop the API, and start it with
CLASSIFIER_MODE=unavailable($env:CLASSIFIER_MODE = "unavailable"in PowerShell). Send a ticket. You get503, and the log has aWARNINGwith the same ID. Start it again without the variable. - Start it with
CLASSIFIER_MODE=magic. It stops at once withValueError: unknown CLASSIFIER_MODE 'magic'.
Your turn: a support request
Section titled “Your turn: a support request”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:
- Remove the log line. Log only facts: method, path, status, duration, ID.
- Treat every key that appeared in the logs as leaked: cancel it, and make a new one.
- If you need more detail while you debug, use a
DEBUGlog 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.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
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-IDheader and the error body, and logs it. - Log facts about requests, never their content, headers or secrets.
conftest.pyholds 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