リクエストが失敗してもリソースを閉じる:設計原理
一言でいうと
起動・終了・例外の経路を分離して、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つ書いてみてください。