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での扱い |
|---|---|---|---|
type | string | 問題種別を識別するURI reference | 分岐の主識別子にする |
status | number | origin serverが返したHTTP status | 実際のHTTP statusと一致させる |
title | string | 問題種別の短い説明 | 原則、発生ごとに変えない |
detail | string | 今回の発生に固有の説明 | 人向け。機械的にparseしない |
instance | string | 今回の発生を識別する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形式が不正 | 400 | requestを修正する |
| 認証情報がない・無効 | 401 | 認証手順へ戻る |
| 認証済みだが権限がない | 403 | 権限を確認する |
| resourceが存在しない | 404 | IDや公開範囲を確認する |
| 現在状態と操作が競合 | 409 | 最新状態を取得して判断する |
| 条件付き更新に失敗 | 412 | 新しいETagで再編集する |
| 入力値を処理できない | 422 | errorsを基に修正する |
| 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、自動化処理が同じ失敗分類と再処理ルールを共有できます。

