TT Lab
はじめる
学ぶ 学習パス コース

FastAPI — 型がそのまま契約だ

リクエストが失敗してもリソースを閉じる:設計原理

TT Labで続きを見る

一言でいうと

起動・終了・例外の経路を分離して、FastAPIのlifespanを実際に実行します。

なぜ必要なのか

テストは通ったのに、本番の再起動のときに接続が残りました。TestClientをcontext managerなしで使ったため、起動と終了のコードが実行されていなかったのです。正常なレスポンスを1回見るだけのテストでは、アプリがリソースをいつ開いて、いつ閉じるのかわかりません。ここでは、外部の接続の代わりに、イベントを記録する小さなリソースで、ライフサイクルを観察します。

どう動くのか

開く処理と閉じる処理を分離したうえで、contextmanagerのfinallyでまとめます。同じリソースを2回開くのはエラーで、すでに閉じたリソースをもう一度閉じるのは、安全な何もしない動作です。FastAPIのlifespanは、asynccontextmanagerを使い、起動時にapp.stateへリソースを接続します。with TestClientの中では、準備状態を読み取れ、ブロックを抜けたり例外が出たりしたら、closeのイベントが、ちょうど1回だけ残らなければなりません。

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

契約を読んで失敗を予測するワークシート

以下は、実装をまるごと暗記するための答案ではなく、ステップごとのコードレビューです。各変更の断片は、意図的に契約を壊しています。変更したあとでも、正常な例は通ることがある点に注意してください。実行する前に、どの入力・例外・状態を観測すれば違いが表に出るかを予想し、実装したあとで、その予想と結果を比べます。

1. リソースの状態を独立させる

new_resource()は、{open:False, events:[]}の新しい辞書で、呼び出し同士でeventsを共有しません。

判断の根拠: 変更可能なリストを、グローバルやデフォルト引数で共有しません。

レビューする誤った変更の断片:

"open":True

この断片が入った関数の公開契約と比べてみてください。成功例1つでは区別できないなら、拒否されるべき入力や、失敗したあとの状態を観測の対象に選びます。

2. 重複した開始を拒否する

start(resource)は、すでに開いていればValueError、そうでなければopen=Trueに変えて、eventsに'open'を追加します。

判断の根拠: 2回開始して、リソースを1つ失ってしまう動作を拒否します。

レビューする誤った変更の断片:

if False:
        raise

この断片が入った関数の公開契約と比べてみてください。成功例1つでは区別できないなら、拒否されるべき入力や、失敗したあとの状態を観測の対象に選びます。

3. 終了を冪等にする

stop(resource)は、開いているときだけopen=Falseに変えて、'close'をeventsに追加します。すでに閉じていれば、そのままにします。

判断の根拠: 複数の後始末の経路が重なっても、重複したcloseのイベントが出てはいけません。

レビューする誤った変更の断片:

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

この断片が入った関数の公開契約と比べてみてください。成功例1つでは区別できないなら、拒否されるべき入力や、失敗したあとの状態を観測の対象に選びます。

4. 閉じたリソースの使用を防ぐ

read(resource)は、閉じていればRuntimeError、開いていれば{ready:True}を返します。

判断の根拠: 準備状態と、オブジェクトの存在は、別です。オブジェクトがあっても、閉じていることがあります。

レビューする誤った変更の断片:

if False:

この断片が入った関数の公開契約と比べてみてください。成功例1つでは区別できないなら、拒否されるべき入力や、失敗したあとの状態を観測の対象に選びます。

5. 例外の経路にfinallyを置く

scope(resource)は、contextmanagerです。入るときにstart、ブロックの中にはresourceをyieldし、ブロックの成功でも失敗でも、stopで閉じます。ブロックの例外は伝播します。

判断の根拠: yieldのあとだけにcloseを書くと、例外が出た場合、その行に到達できません。

レビューする誤った変更の断片:

pass

この断片が入った関数の公開契約と比べてみてください。成功例1つでは区別できないなら、拒否されるべき入力や、失敗したあとの状態を観測の対象に選びます。

6. アプリのライフサイクルとリソースをつなぐ

lifespan_for(resource)は、asynccontextmanagerの関数lifespan(app)を返します。scope(resource)の中でapp.state.resourceを設定して、yieldします。

判断の根拠: lifespanの関数自体を呼び出すのではなく、FastAPIのコンストラクターに渡します。

レビューする誤った変更の断片:

app.state.resource = dict(resource)

この断片が入った関数の公開契約と比べてみてください。成功例1つでは区別できないなら、拒否されるべき入力や、失敗したあとの状態を観測の対象に選びます。

7. 準備状態を実際のリクエストで読む

create_app(resource)は、lifespan_forを使います。GET /readyは、app.state.resourceをreadした結果を返します。contextの終了時に、リソースを閉じなければなりません。

判断の根拠: with TestClientを使ってはじめて、lifespanの起動と終了を、どちらも実行します。

レビューする誤った変更の断片:

FastAPI()

この断片が入った関数の公開契約と比べてみてください。成功例1つでは区別できないなら、拒否されるべき入力や、失敗したあとの状態を観測の対象に選びます。

8. リクエストのあとの失敗も後始末する

exercise(resource, fail=False)は、with TestClient(create_app(resource))の中で、GET /readyを呼び出します。fail=Trueなら、その中でRuntimeErrorを出し、そうでなければレスポンスのJSONを返します。どちらの場合も、リソースが閉じられていなければなりません。

判断の根拠: 正常な経路と例外の経路を、同じ後始末の構造にまとめれば、抜けている終了の経路を減らせます。

レビューする誤った変更の断片:

if False:

この断片が入った関数の公開契約と比べてみてください。成功例1つでは区別できないなら、拒否されるべき入力や、失敗したあとの状態を観測の対象に選びます。

現場での姿

学習用のリソースの辞書は、実際のDB接続プールの代わりになる、観測のための仕組みです。本番では、部分的な初期化の失敗、接続プールの並行性、キャンセルの処理と終了のタイムアウトも、設計する必要があります。イベントの文字列をレポートに書くのではなく、学習者のコードが実行して変えたオブジェクトの状態を検査します。

次のラボですること

8つのステップが、1つの実行可能な成果物につながります。リソースの状態を独立させる → 重複した開始を拒否する → 終了を冪等にする → 閉じたリソースの使用を防ぐ → 例外の経路にfinallyを置く → アプリのライフサイクルとリソースをつなぐ → 準備状態を実際のリクエストで読む → リクエストのあとの失敗も後始末する、という流れです。

各ステップは、関数やファイルが存在するという事実ではなく、実際の戻り値・例外・状態の変化を検査します。正解を見たあとには、わざと境界の比較や後始末のコードを変えて、どのテストが失敗するかを確認してください。前のテストが次のステップでも維持される理由を説明し、このラボが保証しない本番の条件を1つ書いてみてください。