TT Lab
Get started
Learn Learning paths Courses

FastAPI — Types Are the Contract

Close resources even when requests fail: design principles

Continue in TT Lab

Summary

Separate the startup, shutdown, and exception paths, and actually run the FastAPI lifespan.

Why this matters

The tests passed, but connections were left over when production restarted. The startup and shutdown code had not run because TestClient was used without a context manager. A test that looks at one normal response cannot tell you when the app opens and closes its resources. Here, instead of an external connection, we observe the lifecycle with a small resource that records events.

How it works

Separate opening from closing, and then tie them together with the finally of a contextmanager. Opening the same resource twice is an error, and closing an already closed resource is a safe no-op. The FastAPI lifespan uses asynccontextmanager and connects the resource to app.state at startup. Inside with TestClient you can read the ready state, and when you leave the block or an exception occurs, exactly one close event must be recorded.

닫힘 → start → 열림 → 요청 → finally stop → 닫힘
                         └ 오류 ────────┘

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. Make the resource state independent

new_resource() is a new dictionary {open:False, events:[]}, and separate calls do not share events.

Basis for the judgment: do not share a mutable list through a global or a default argument.

Faulty change fragment to review:

"open":True

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. Reject a duplicate start

start(resource) raises ValueError if it is already open; otherwise it sets open=True and appends 'open' to events.

Basis for the judgment: reject the behavior of starting twice and losing one resource.

Faulty change fragment to review:

if False:
        raise

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. Make shutdown idempotent

stop(resource) sets open=False and appends 'close' to events only when it is open. If it is already closed, it leaves things as they are.

Basis for the judgment: even when several cleanup paths overlap, a duplicate close event must not appear.

Faulty change fragment to review:

resource["events"].append("closed")

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. Block the use of a closed resource

read(resource) raises RuntimeError if it is closed, and returns {ready:True} if it is open.

Basis for the judgment: the ready state and the existence of the object are different things. The object can exist and still be closed.

Faulty change fragment to review:

if False:

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. Put finally on the exception path

scope(resource) is a contextmanager. On entry it calls start, inside the block it yields resource, and on both success and failure of the block it closes with stop. An exception in the block is propagated.

Basis for the judgment: if you write the close only after the yield, that line is never reached when an exception occurs.

Faulty change fragment to review:

pass

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. Connect the app lifespan to the resource

lifespan_for(resource) returns an asynccontextmanager function lifespan(app). Inside scope(resource), it sets app.state.resource and yields.

Basis for the judgment: you do not call the lifespan function itself; you pass it to the FastAPI constructor.

Faulty change fragment to review:

app.state.resource = dict(resource)

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. Read the ready state through a real request

create_app(resource) uses lifespan_for. GET /ready returns the result of read on app.state.resource. The resource must be closed when the context ends.

Basis for the judgment: you have to use with TestClient for both the lifespan startup and shutdown to run.

Faulty change fragment to review:

FastAPI()

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. Clean up after a failure following the request

exercise(resource, fail=False) calls GET /ready inside with TestClient(create_app(resource)). If fail=True, it raises RuntimeError inside, and otherwise it returns the response JSON. In both cases the resource must be closed.

Basis for the judgment: if you bind the normal path and the exception path into the same cleanup structure, you reduce the number of shutdown paths you miss.

Faulty change fragment to review:

if False:

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 teaching resource dictionary is an observation device that stands in for a real DB connection pool. In production you also have to design for partial initialization failure, concurrency of the connection pool, cancellation handling, and a shutdown time limit. Instead of writing event strings in a report, you check the state of objects that the learner's code changed as it ran.

What you will do in the next lab

Eight steps lead to one runnable result. Make the resource state independent → reject a duplicate start → make shutdown idempotent → block the use of a closed resource → put finally on the exception path → connect the app lifespan to the resource → read the ready state through a real request → clean up after a failure following the request.

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.