APIエラーを機械処理できる設計|RFC 9457 Problem Detailsの実務

「APIエラーを機械処理できる設計|RFC 9457 Problem Detailsの実務」の内容を表す技術イラスト

API連携で正常responseだけを設計し、エラー時は{"error":"失敗しました"}のような独自JSONを返すと、利用側はAPIごとに別の分岐を書かなければなりません。HTTP statusだけでも、入力のどこを直すべきか、同じrequestを再試行してよいか、問い合わせ時に何を伝えるべきかまでは分かりません。

RFC 9457のProblem Detailsは、HTTP APIのエラー詳細を機械可読な共通構造で表す仕様です。この記事では、標準fieldの意味、入力検証エラーの拡張、再試行、Python実装、機密情報の分離までを一つのエラー契約として整理します。

目次

Problem Detailsが解決すること

Problem Detailsは、HTTP statusを置き換える仕組みではありません。HTTP statusで大分類を伝え、response bodyで問題の種類と発生内容を補います。

最小例は次のとおりです。

HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json

{
  "type": "https://example.com/problems/validation-error",
  "title": "Request validation failed",
  "status": 422,
  "detail": "2 fields contain invalid values.",
  "instance": "/problems/req-7f3a"
}

JSON全体がobjectで、statusはnumber、ほかの標準fieldはstringです。media typeにはapplication/problem+jsonを使います。

これにより、API clientは次のように役割を分けられます。

  • HTTP status:認証、競合、入力不正、サーバー障害などの大分類
  • type:アプリケーション固有の問題種別を機械判定
  • detail:利用者へ表示する今回の説明
  • instance:ログや問い合わせで今回の発生を照合

5つの標準fieldを役割で分ける

RFC 9457が定義する標準memberは、type、status、title、detail、instanceです。すべてを必ず入れるという意味ではありませんが、同じ情報を重複させず役割を固定します。

field型役割clientでの扱い
typestring問題種別を識別するURI reference分岐の主識別子にする
statusnumberorigin serverが返したHTTP status実際のHTTP statusと一致させる
titlestring問題種別の短い説明原則、発生ごとに変えない
detailstring今回の発生に固有の説明人向け。機械的にparseしない
instancestring今回の発生を識別するURI reference問い合わせ・ログ照合に使う

typeは安定した機械識別子

typeはproblem typeの主識別子です。表示文言ではなくURIで比較します。可能なら、自分が管理するdomain上の絶対URIを使い、人が開いたときに意味や対処方法を確認できる文書へします。

https://example.com/problems/validation-error
https://example.com/problems/version-conflict
https://example.com/problems/rate-limit-exceeded

example.comは説明用です。実装時は管理下の安定したURIへ置き換えます。clientがこの値を分岐に使うため、typeの変更はインターフェース変更になります。

typeを省略した場合はabout:blankとみなされます。単にHTTP statusの意味を使うだけなら利用できますが、clientに固有の処理をさせたい問題には明示的なtypeを定義します。

titleとdetailは別物

titleは問題種別の短い要約で、言語切替を除けば発生ごとに変えないことが推奨されます。一方、detailは今回の発生に固有の説明です。

{
  "title": "Resource version conflict",
  "detail": "The asset was updated after version 7 was retrieved."
}

clientはdetailからversion番号を正規表現で抜き出してはいけません。機械処理に必要なら、current_versionなど型を持つ拡張fieldとして返します。

instanceは発生単位の参照ID

同じtypeのエラーは何度も発生します。instanceはその一回を識別します。

{
  "instance": "/problems/req-7f3a"
}

必ずしも公開URLとして取得可能にする必要はありません。clientには不透明な参照値として渡し、サーバーの監査ログに同じIDを保存できます。DB名、内部file path、stack traceなどをinstanceへ埋め込まないようにします。

入力からエラーresponseまでの流れ

API内部では、発生した例外をそのままJSON化せず、境界で公開用problem typeへ変換します。

HTTP request
  ↓
認証・認可
  ↓
JSON構文とContent-Typeを検証
  ↓
schema・型・必須fieldを検証
  ↓
業務ルールと現在状態を検証
  ↓
内部例外を公開用problem typeへ変換
  ↓
HTTP status + application/problem+json
  ↓
instance IDで監査ログと関連付け

たとえばJSONの構文不正と、JSONとしては正しいが圧力値が範囲外の状態は分けます。前者はrequest自体を解析できない問題、後者はfield単位のvalidation問題です。

検証エラーは拡張fieldで配列化する

Problem Detailsには固有のmemberを追加できます。複数fieldの入力不正にはerrors arrayを定義します。

{
  "type": "https://example.com/problems/validation-error",
  "title": "Request validation failed",
  "status": 422,
  "detail": "2 fields contain invalid values.",
  "instance": "/problems/req-7f3a",
  "errors": [
    {
      "pointer": "/pressure/value",
      "code": "minimum",
      "message": "0以上の数値を指定してください"
    },
    {
      "pointer": "/pressure/unit",
      "code": "enum",
      "message": "Pa、kPa、MPa、barのいずれかを指定してください"
    }
  ]
}

pointerはrequest JSON内の位置、codeは機械判定用の安定値、messageは人向け文言です。RFC 6901のJSON Pointerを採用すれば、入れ子のfieldも曖昧なく示せます。

clientは未知の拡張memberを無視できるようにします。将来allowed_valuesなどを追加しても、既存clientが壊れない契約にできます。逆に、既存memberの型や意味を変更するのは避けます。

HTTP statusとproblem typeを対応させる

typeだけでなくHTTP statusも正しく選びます。status codeをすべて200にしてbodyだけで失敗を示すと、HTTP client、proxy、監視、再試行制御が正常と誤認します。

状況status例clientの基本動作
JSON構文・request形式が不正400requestを修正する
認証情報がない・無効401認証手順へ戻る
認証済みだが権限がない403権限を確認する
resourceが存在しない404IDや公開範囲を確認する
現在状態と操作が競合409最新状態を取得して判断する
条件付き更新に失敗412新しいETagで再編集する
入力値を処理できない422errorsを基に修正する
request過多429指定時間後に再試行する
一時的に利用不能503待機後に再試行を検討する

同じ422でも、設計値の範囲違反と未対応単位を別のtypeにするか、同じvalidation errorのcodeで分けるかを決めます。clientの復旧操作が同じなら共通type、操作が異なるなら別typeにする考え方が使えます。

再試行可否はdetailから推測しない

「後でもう一度試してください」という文章をclientがparseして再試行する設計は不安定です。HTTP status、problem type、response headerを組み合わせます。

429や503など待機が有効な状況では、必要に応じてRetry-After headerを返します。

HTTP/1.1 503 Service Unavailable
Content-Type: application/problem+json
Retry-After: 120

{
  "type": "https://example.com/problems/temporarily-unavailable",
  "title": "Service temporarily unavailable",
  "status": 503,
  "detail": "The calculation service is temporarily unavailable.",
  "instance": "/problems/req-a812"
}

ただし、POSTを自動再送すると二重登録や二重実行になる場合があります。再試行可能な通信エラーでも、request IDやidempotency key、DBの一意制約などがなければ安全に再実行できるとは限りません。

入力不正、権限不足、業務上の競合は、時間を置くだけでは通常解決しません。再試行回数を増やす前に、問題ごとの復旧操作を契約へ記載します。

Pythonで共通生成関数を作る

Web framework固有の処理からproblem objectの生成を分離すると、複数endpointで同じ契約を再利用できます。

import json
import uuid
from typing import Any


def build_problem(
    *,
    status: int,
    type_uri: str,
    title: str,
    detail: str,
    extensions: dict[str, Any] | None = None,
) -> tuple[int, dict[str, str], bytes]:
    if not 400 <= status <= 599:
        raise ValueError("statusは400以上599以下が必要です")
    if not type_uri.startswith("https://"):
        raise ValueError("type_uriは管理下のHTTPS URIを指定してください")

    instance_id = f"req-{uuid.uuid4().hex}"
    body: dict[str, Any] = {
        "type": type_uri,
        "title": title,
        "status": status,
        "detail": detail,
        "instance": f"/problems/{instance_id}",
    }

    if extensions:
        reserved = {"type", "title", "status", "detail", "instance"}
        if reserved & extensions.keys():
            raise ValueError("標準fieldをextensionsで上書きできません")
        body.update(extensions)

    payload = json.dumps(
        body,
        ensure_ascii=False,
        separators=(",", ":"),
    ).encode("utf-8")

    headers = {"Content-Type": "application/problem+json"}
    return status, headers, payload

入力検証エラーなら次のように呼び出します。

status, headers, payload = build_problem(
    status=422,
    type_uri="https://example.com/problems/validation-error",
    title="Request validation failed",
    detail="1 field contains an invalid value.",
    extensions={
        "errors": [
            {
                "pointer": "/pressure/value",
                "code": "minimum",
                "message": "0以上の数値を指定してください",
            }
        ]
    },
)

framework側は、戻り値のstatusを実際のHTTP statusへ、headersをresponse headerへ、payloadをbodyへ設定します。body内のstatusだけを変えてHTTP statusを200のままにしてはいけません。

正常系と異常系を検証する

エラーresponseも公開インターフェースなので、自動テストの対象です。

  • HTTP statusとbodyのstatusが一致する
  • Content-Typeがapplication/problem+jsonである
  • 同じproblem typeではtypeとtitleが安定している
  • 発生ごとに異なるinstanceが生成される
  • errorsがarrayで、pointerとcodeの型が一定である
  • 未知の拡張memberがあってもclientが処理を継続できる
  • 401、403、404でresourceの存在や内部情報を漏らさない
  • 500系responseに例外文、SQL、stack trace、ローカルpathが含まれない
  • 429・503の再試行条件とRetry-Afterをclientが正しく扱う

セキュリティと公開情報を分離する

Problem Detailsはdebug dumpではありません。利用者が問題を修正するための情報と、運用者が原因を調べる内部情報を分けます。

公開responseに含めない情報の例は次のとおりです。

  • stack trace、例外class、SQL文、table名
  • サーバーのfile path、hostname、内部endpoint
  • API key、token、credentialの一部または全部
  • 他利用者のID、存在確認につながる情報
  • 未公開の設計値、設備条件、図面名

詳細な例外、入力の安全な要約、処理node、内部trace IDはアクセス制御されたlogへ保存します。clientへはinstanceを返し、運用者が同じIDで内部logを検索します。

認証前の404と認証後の404で説明を変えると、resourceの存在を推測される場合があります。エラー文の親切さだけでなく、公開してよい事実かを確認します。

DB・CAD・自動化へ再利用する

Problem Detailsの構造は、HTTP APIだけでなく、CSV取込やCAD属性同期のエラー記録にも展開できます。

たとえば取込処理では、type、instance、source_record_id、pointer、codeをerror tableへ保存します。画面表示用の文章だけでなく、機械識別子を残すことで、同じ原因の集計、修正後の再実行、未対応typeの隔離が可能になります。

CREATE TABLE integration_errors (
    instance_id     TEXT PRIMARY KEY NOT NULL,
    problem_type    TEXT NOT NULL,
    source_record_id TEXT,
    field_pointer   TEXT,
    error_code      TEXT,
    occurred_at     TEXT NOT NULL
);

元データ全体や秘密情報を無条件に保存せず、再処理に必要な参照IDと安全な分類を中心にします。正常recordと異常recordを分離すれば、一件の不正データで全件処理を停止するか、隔離して継続するかも明示できます。

まとめ

APIエラーは文章ではなく、clientが復旧操作を選べるデータ契約として設計します。

  • HTTP statusで問題の大分類を伝える
  • responseにはapplication/problem+jsonを使う
  • typeを安定した問題種別の主識別子にする
  • titleは種別共通、detailは発生固有にする
  • detailを機械的にparseせず、必要な値は拡張fieldへ分ける
  • instanceで公開responseと内部logを関連付ける
  • validation errorはpointer・code・messageを配列化する
  • 再試行判断にはstatus、type、Retry-After、冪等性を使う
  • 未知の拡張fieldを許容し、既存fieldの意味を変えない
  • stack trace、SQL、credential、機密データを公開しない

Problem DetailsをAPI共通部品として実装すれば、endpointごとの独自エラー形式を減らし、Python、DB、CSV、CAD、自動化処理が同じ失敗分類と再処理ルールを共有できます。

参考情報

参考になったらシェアしてください
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

機械設計・油圧・CAD・Python・AIなど、ものづくりに関わる技術を扱っています。工学知識を整理・構造化し、設計や自動化に再利用できる形へ変えていくことを目指しています。

目次