TT Lab
Get started
Learn Learning paths Courses

FastAPI — Types Are the Contract

Errors are API contracts too: design principles

Continue in TT Lab

Summary

Distinguish business exceptions from internal errors and provide a consistent problem response.

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

How it works

Business errors get a stable code such as missing, conflict, or invalid. Convert the code into an HTTP status and a public sentence, but do not copy the original exception message into the public body. Restrict the request id by length and character set, and put the same value in the body and in the response header. Verify both expected business errors and an unexpected RuntimeError with real TestClient requests.

업무 예외 → code → 상태·공개 메시지
내부 예외 → 고정 500 → 세부 내용 숨김
검증한 요청 id ─────────→ 헤더와 본문

A worksheet for reading the contract and predicting failures

What follows is not an answer key to memorize an implementation but a step-by-step code review. Each change fragment deliberately breaks the contract. Note that the normal case may still pass after the change. Before running it, predict which input, exception, or state you would observe to expose the difference, and after implementing it, compare that prediction with the result.

1. Keep a code on the business exception

DomainError(code, message) is a subclass of Exception and stores the code in .code. str(exception) is the message.

Basis for the judgment: separate the code that a machine decides on from the internal message that a person sees.

Faulty change fragment to review:

self.code = "invalid"

Compare it with the public contract of the function this fragment sits in. If a single success case does not tell them apart, choose as your observation target an input that should be rejected or the state after a failure.

2. Map the code to a status

status_for(code) is missing=404, conflict=409, invalid=422, and anything else=500.

Basis for the judgment: never treat an unknown code as a success.

Faulty change fragment to review:

.get(code, 200)

Compare it with the public contract of the function this fragment sits in. If a single success case does not tell them apart, choose as your observation target an input that should be rejected or the state after a failure.

3. Fix the public sentence

public_message(code) is missing='Resource not found', conflict='State conflict', invalid='Invalid request', and anything else='Internal error'.

Basis for the judgment: even if the exception string contains a DB address or an internal path, it is not exposed.

Faulty change fragment to review:

"invalid":"Internal error"

Compare it with the public contract of the function this fragment sits in. If a single success case does not tell them apart, choose as your observation target an input that should be rejected or the state after a failure.

4. Restrict the request id

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.

Basis for the judgment: restrict both the length and the character set so that an arbitrary header is not reflected back.

Faulty change fragment to review:

{1,64}

Compare it with the public contract of the function this fragment sits in. If a single success case does not tell them apart, choose as your observation target an input that should be rejected or the state after a failure.

5. Build the problem body

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

Basis for the judgment: if the body and the HTTP status disagree, the client cannot decide which value to trust.

Faulty change fragment to review:

"status":500

Compare it with the public contract of the function this fragment sits in. If a single success case does not tell them apart, choose as your observation target an input that should be rejected or the state after a failure.

6. Make the response format consistent

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.

Basis for the judgment: returning a plain dictionary can turn even an error into a 200.

Faulty change fragment to review:

media_type="application/json"

Compare it with the public contract of the function this fragment sits in. If a single success case does not tell them apart, choose as your observation target an input that should be rejected or the state after a failure.

7. Connect the business exception handler

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.

Basis for the judgment: do not scatter the places where exceptions are caught; put them at the common boundary of the app.

Faulty change fragment to review:

response_for("invalid", request.headers.get("x-request-id"))

Compare it with the public contract of the function this fragment sits in. If a single success case does not tell them apart, choose as your observation target an input that should be rejected or the state after a failure.

8. Hide unexpected errors too

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.

Basis for the judgment: in the test, turn off re-raising of exceptions and check the bytes of the actual 500 response.

Faulty change fragment to review:

response_for("invalid", request.headers.get("x-request-id"))

Compare it with the public contract of the function this fragment sits in. If a single success case does not tell them apart, choose as your observation target an input that should be rejected or the state after a failure.

What it looks like in the field

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.

What you will do in the next lab

Eight steps lead to one runnable result. Keep a code on the business exception → map the code to a status → fix the public sentence → restrict the request id → build the problem body → make the response format consistent → connect the business exception handler → hide unexpected errors too.

Each step checks actual return values, exceptions, and state changes, not the fact that a function or file exists. After you see the answer, deliberately change a boundary comparison or the cleanup code and check which tests fail. Explain why the earlier tests are kept in the next steps, and write down one operating condition that this lab does not guarantee.