FastAPI — Types Are the Contract
Errors are API contracts too
Goal
Distinguish business exceptions from internal errors and provide a consistent problem response.
Why it matters
A client was reading the detail string of an error with a regular expression. One day the wording changed, and an out-of-stock error was even handled as a payment retry. On another route, the exception string was exposed as it was, and SQL and internal paths leaked out. Errors need an explicit contract just as much as success responses do.
Steps
- In
/root/work/fa-problem-lab/service.py, DomainError(code, message) is a subclass of Exception and stores the code in .code. str(exception) is the message.
Prepare this once at the start. Existing files are not overwritten.
mkdir -p /root/work/fa-problem-lab
test -e /root/work/fa-problem-lab/service.py || cp /opt/fixtures/ten_labs/fa-problem-lab/service.py /root/work/fa-problem-lab/service.py
cd /root/work/fa-problem-lab
-
In
/root/work/fa-problem-lab/service.py, status_for(code) is missing=404, conflict=409, invalid=422, and anything else=500. -
In
/root/work/fa-problem-lab/service.py, public_message(code) is missing='Resource not found', conflict='State conflict', invalid='Invalid request', and anything else='Internal error'. -
In
/root/work/fa-problem-lab/service.py, request_id(value) returns the value as it is if it is a string of 1–32 characters made only of ASCII letters, digits, underscores, and hyphens, and 'untracked' otherwise. -
In
/root/work/fa-problem-lab/service.py, problem(code, rid) is a dictionary that has only type='urn:labhub:problem:'+code, title and detail=public_message(code), status=status_for(code), and request_id=request_id(rid). -
In
/root/work/fa-problem-lab/service.py, response_for(code, rid) is a JSONResponse with problem as the body, status_for as the status, application/problem+json as the media_type, and X-Request-ID set to the normalized rid. -
In
/root/work/fa-problem-lab/service.py, install_handlers(app) registers a DomainError handler. It reads the X-Request-ID header and returns response_for for exc.code. It does not put the message of exc into the response. -
In
/root/work/fa-problem-lab/service.py, create_app() installs the handlers and raises DomainError(code, internal message) at GET /fail/{code}. But when code=boom it raises RuntimeError. The RuntimeError handler returns a fixed 500 problem response with code=internal.
Notes
- You work in the existing lab-dev environment with no internet and no package installation.
- Each step runs within a 45-second grading budget. Do not add real sleeps or network calls.
- The grader loads the submitted module fresh and checks it with independent inputs and a temporary DB. Implement the contract instead of returning the expected values as constants.
- FastAPI official documentation · pytest official documentation · Python sqlite3
- Limitation: the problem response in this lab is a teaching contract with type, title, status, detail, and request_id. It does not claim general internationalization or full standards conformance. A request id is a string used for tracing, not a means of authentication, and secret values must not be recorded as they are in production logs either.
Keep a code on the business exception
In /root/work/fa-problem-lab/service.py, DomainError(code, message) is a subclass of Exception and stores the code in .code. str(exception) is the message.
Prepare this once at the start. Existing files are not overwritten.
mkdir -p /root/work/fa-problem-lab
test -e /root/work/fa-problem-lab/service.py || cp /opt/fixtures/ten_labs/fa-problem-lab/service.py /root/work/fa-problem-lab/service.py
cd /root/work/fa-problem-lab
Separate the code that a machine decides on from the internal message that a person sees.
After saving, check with bash /opt/lab/checks/fa-problem-lab/01-contract.sh.
Map the code to a status
In /root/work/fa-problem-lab/service.py, status_for(code) is missing=404, conflict=409, invalid=422, and anything else=500.
Never treat an unknown code as a success.
After saving, check with bash /opt/lab/checks/fa-problem-lab/02-contract.sh.
Fix the public sentence
In /root/work/fa-problem-lab/service.py, public_message(code) is missing='Resource not found', conflict='State conflict', invalid='Invalid request', and anything else='Internal error'.
Even if the exception string contains a DB address or an internal path, it is not exposed.
After saving, check with bash /opt/lab/checks/fa-problem-lab/03-contract.sh.
Restrict the request id
In /root/work/fa-problem-lab/service.py, request_id(value) returns the value as it is if it is a string of 1–32 characters made only of ASCII letters, digits, underscores, and hyphens, and 'untracked' otherwise.
Restrict both the length and the character set so that an arbitrary header is not reflected back.
After saving, check with bash /opt/lab/checks/fa-problem-lab/04-contract.sh.
Build the problem body
In /root/work/fa-problem-lab/service.py, problem(code, rid) is a dictionary that has only type='urn:labhub:problem:'+code, title and detail=public_message(code), status=status_for(code), and request_id=request_id(rid).
If the body and the HTTP status disagree, the client cannot decide which value to trust.
After saving, check with bash /opt/lab/checks/fa-problem-lab/05-contract.sh.
Make the response format consistent
In /root/work/fa-problem-lab/service.py, response_for(code, rid) is a JSONResponse with problem as the body, status_for as the status, application/problem+json as the media_type, and X-Request-ID set to the normalized rid.
Returning a plain dictionary can turn even an error into a 200.
After saving, check with bash /opt/lab/checks/fa-problem-lab/06-contract.sh.
Connect the business exception handler
In /root/work/fa-problem-lab/service.py, install_handlers(app) registers a DomainError handler. It reads the X-Request-ID header and returns response_for for exc.code. It does not put the message of exc into the response.
Do not scatter the places where exceptions are caught; put them at the common boundary of the app.
After saving, check with bash /opt/lab/checks/fa-problem-lab/07-contract.sh.
Hide unexpected errors too
In /root/work/fa-problem-lab/service.py, create_app() installs the handlers and raises DomainError(code, internal message) at GET /fail/{code}. But when code=boom it raises RuntimeError. The RuntimeError handler returns a fixed 500 problem response with code=internal.
In the test, turn off re-raising of exceptions and check the bytes of the actual 500 response.
After saving, check with bash /opt/lab/checks/fa-problem-lab/08-contract.sh.