エラー応答もAPIの契約:設計原理
一言でいうと
業務例外と内部エラーを区別し、一貫した問題レスポンスを提供します。
なぜ必要なのか
クライアントは、エラーが出ると、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つ書いてみてください。