TT Lab
Get started
Learn Learning paths Courses

FastAPI — Types Are the Contract

Errors are API contracts too

Continue in TT Lab

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

  1. 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
  1. In /root/work/fa-problem-lab/service.py, status_for(code) is missing=404, conflict=409, invalid=422, and anything else=500.

  2. 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'.

  3. 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.

  4. 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).

  5. 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.

  6. 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.

  7. 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

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.