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

FastAPI — 型がそのまま契約だ

エラー応答もAPIの契約:設計原理

TT Labで続きを見る

一言でいうと

業務例外と内部エラーを区別し、一貫した問題レスポンスを提供します。

なぜ必要なのか

クライアントは、エラーが出ると、detailの文字列を正規表現で読んでいました。ある日、文言が変わると、在庫不足も決済のリトライとして処理されました。別の経路では、例外の文字列がそのまま公開されて、SQLと内部のパスが漏れました。エラーには、成功のレスポンスに劣らず、明示的な契約が必要です。

どう動くのか

業務エラーには、missing、conflict、invalidという、安定したcodeを付けます。codeをHTTPのステータスと公開用の文言に変換しますが、元の例外メッセージを公開する本文にコピーしません。リクエストidは、長さと文字集合を制限し、本文とレスポンスヘッダーに同じ値を入れます。予想された業務エラーと、予想していないRuntimeErrorのどちらも、実際のTestClientのリクエストで確認します。

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

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

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

1. 業務例外にcodeを残す

DomainError(code, message)は、Exceptionのサブクラスで、.codeにcodeを保持します。str(例外)は、messageです。

判断の根拠: 機械が判断するcodeと、人が見る内部メッセージを分離します。

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

self.code = "invalid"

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

2. codeをステータスにマッピングする

status_for(code)は、missing=404、conflict=409、invalid=422、それ以外=500です。

判断の根拠: 未知のcodeを、成功として扱いません。

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

.get(code, 200)

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

3. 公開用の文言を固定する

public_message(code)は、missing='Resource not found'、conflict='State conflict'、invalid='Invalid request'、それ以外='Internal error'です。

判断の根拠: 例外の文字列にDBのアドレスや内部のパスが入っていても、公開しません。

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

"invalid":"Internal error"

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

4. リクエストidを制限する

request_id(value)は、ASCIIの英字・数字・アンダースコア・ハイフンだけで構成された1–32文字の文字列ならそのまま、そうでなければ'untracked'です。

判断の根拠: 任意のヘッダーをそのまま返さないように、長さと文字集合を一緒に制限します。

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

{1,64}

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

5. 問題の本文を作る

problem(code, rid)は、type='urn:labhub:problem:'+code、titleとdetail=public_message(code)、status=status_for(code)、request_id=request_id(rid)だけを持つ辞書です。

判断の根拠: 本文とHTTPのステータスが食い違うと、クライアントはどちらの値を信じるか決められません。

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

"status":500

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

6. レスポンスの形式を一貫させる

response_for(code, rid)は、problemを本文に、status_forをステータスに、application/problem+jsonをmedia_typeに、X-Request-IDを正規化したridにしたJSONResponseです。

判断の根拠: 単純な辞書を返すだけでは、エラーも200になってしまうことがあります。

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

media_type="application/json"

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

7. 業務例外のハンドラーをつなぐ

install_handlers(app)は、DomainErrorのハンドラーを登録します。ヘッダーX-Request-IDを読んで、exc.codeに対してresponse_forを返します。excのmessageは、レスポンスに入れません。

判断の根拠: 例外を捕まえる場所を散らさず、アプリの共通の境界に置きます。

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

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

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

8. 予想していないエラーも隠す

create_app()は、ハンドラーをインストールして、GET /fail/{code}でDomainError(code, 内部の文言)を出します。ただしcode=boomならRuntimeErrorを出します。RuntimeErrorのハンドラーは、code=internalの固定の500の問題レスポンスを返します。

判断の根拠: テストで例外の再送出を無効にして、実際の500のレスポンスのバイト列を確認します。

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

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

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

現場での姿

このラボの問題レスポンスは、type・title・status・detail・request_idを持つ、学習用の契約です。汎用の国際化や、標準への完全な適合性は主張しません。request idは、追跡に使う文字列であって、認証の手段ではなく、本番のログにも、秘密の値をそのまま記録してはいけません。

次のラボですること

8つのステップが、1つの実行可能な成果物につながります。業務例外にcodeを残す → codeをステータスにマッピングする → 公開用の文言を固定する → リクエストidを制限する → 問題の本文を作る → レスポンスの形式を一貫させる → 業務例外のハンドラーをつなぐ → 予想していないエラーも隠す、という流れです。

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