Go API のエラー処理パターン:型付きエラー、HTTP ステータスのマッピング、request ID、内部を漏らさない安全なメッセージを標準化する方法。

各エンドポイントが失敗をバラバラに報告すると、クライアントは API を信用できなくなります。あるルートは { "error": "not found" } を返し、別は { "message": "missing" } を返し、さらに別はプレーンテキストを返す。意味は近くても、クライアント側のコードは何が起きたのかを推測しなければならなくなります。
コストはすぐに現れます。チームは壊れやすいパースロジックを作り、エンドポイントごとに特別扱いを追加します。リトライは「後で再試行すべき」か「入力が間違っている」かを区別できないため危険になります。サポートチケットは増え、クライアントに表示されるメッセージは曖昧で、サーバ側のログ行と一致させるのが難しくなります。
よくあるシナリオ:モバイルアプリがサインアップで3つのエンドポイントを呼ぶとします。最初はフィールドレベルのエラーマップで HTTP 400 を返し、2つ目はスタックトレース文字列付きで HTTP 500 を返し、3つ目は { "ok": false } で HTTP 200 を返す。アプリチームは3つの異なるエラーハンドラを用意し、バックエンドチームには「サインアップが時々失敗する」とだけ報告されて、どこから始めればいいか分かりません。
目標は、すべてのエンドポイントが従う予測可能な契約を作ることです。クライアントは、自分の責任かサーバの問題か、リトライが意味を持つか、サポートに貼り付けるリクエスト ID が分かるべきです。
スコープ注記:ここでは JSON HTTP API(gRPC ではない)に焦点を当てますが、同じ考え方は他のシステムにエラーを返す場合にも当てはまります。
エラーに対して1つの明確な契約を選び、すべてのエンドポイントで守らせてください。「一貫性がある」とは、同じ JSON 形、同じフィールドの意味、どのハンドラが失敗しても同じ動作をすることを意味します。そうすればクライアントは推測をやめてエラーを正しく扱えます。
有用な契約はクライアントが次に何をすべきか判断するのに役立ちます。多くのアプリでは、すべてのエラー応答が次の3つの質問に答えるべきです:
実用的なルールセット:
レスポンスに絶対に出してはいけないものを事前に決めてください。一般的な「絶対出すな」項目には SQL 断片、スタックトレース、内部ホスト名、秘密情報、依存先からの生のエラーメッセージなどがあります。
クリーンな分離を保ってください:短いユーザー向けメッセージ(安全で丁寧、実行可能)と内部詳細(完全なエラー、スタック、コンテキスト)はログに残す。例えば「変更を保存できませんでした。もう一度お試しください。」は安全です。一方で「pq: duplicate key value violates unique constraint users_email_key」は表示すべきではありません。
すべてのエンドポイントが同じ契約に従えば、クライアントは1つのエラーハンドラを作ってどこでも使い回せます。
クライアントがエラーをきちんと処理できるのは、すべてのエンドポイントが同じ形で答える場合だけです。1つの JSON 封筒を選び、安定させてください。
実用的なデフォルトは error オブジェクトとトップレベルの request_id です:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "Some fields are invalid.",
"details": {
"fields": {
"email": "must be a valid email address"
}
}
},
"request_id": "req_01HV..."
}
HTTP ステータスは大きなカテゴリ(400, 401, 409, 500)を示します。機械可読な error.code はクライアントが分岐するための具体的なケースを与えます。この分離は重要です。なぜなら多くの異なる問題が同じステータスを共有するからです。モバイルアプリは EMAIL_TAKEN と WEAK_PASSWORD が両方 400 であっても異なる UI を表示するかもしれません。
error.message は安全で人間向けの文にしてください。ユーザーが問題を直せる手助けをしつつ、内部情報(SQL、スタックトレース、プロバイダ名、ファイルパス)を漏らしてはいけません。
オプションフィールドは予測可能であれば便利です:
details.fields をフィールド→メッセージのマップにする。details.retry_after_seconds。details.docs_hint をプレーンテキストで(URL ではない)。後方互換性のために、error.code の値を API 契約の一部として扱ってください。古い意味を変えずに新しいコードを追加し、オプションフィールドだけを追加することでクライアントは未認識のフィールドを無視できます。
エラー処理は各ハンドラが独自の失敗シグナルを作ると混乱します。小さな型付きエラーセットがこれを解決します:ハンドラは既知のエラー型を返し、1つのレスポンス層がそれらを一貫した応答に変換します。
実用的なスターターセットは多くのエンドポイントをカバーします:
重要なのはトップレベルでの安定性です。根本原因が変わっても公開する型は安定させます。低レベルのエラー(SQL、ネットワーク、JSON 解析)をラップしても、ミドルウェアは同じ公開型を検出できます。
type NotFoundError struct {
Resource string
ID string
Err error // private cause
}
func (e NotFoundError) Error() string { return "not found" }
func (e NotFoundError) Unwrap() error { return e.Err }
ハンドラ内では、sql.ErrNoRows を直接漏らすのではなく NotFoundError{Resource: "user", ID: id, Err: err} を返してください。
エラーのチェックには errors.As を優先し、シンボルエラーには errors.Is を使ってください。単純なケースにはセンチネルエラー(例:var ErrUnauthorized = errors.New("unauthorized"))が使えますが、公開用の安全なコンテキスト(どのリソースが見つからなかったかなど)が必要な場合はカスタム型が優れています。
添付する情報を厳格に区別してください:
Err、スタック情報、生の SQL エラー、トークン、ユーザーデータ。その分離により、クライアントを助けつつ内部を晒さずに済みます。
型付きエラーがあれば、次の作業は地味ですが重要です:同じエラー型は常に同じ HTTP ステータスを返すようにします。クライアントはそれに基づいてロジックを構築します。
多くの API に適した実用的なマッピング:
| Error type (example) | Status | When to use it |
|---|---|---|
| BadRequest (malformed JSON, missing required query param) | 400 | The request is not valid at a basic protocol or format level. |
| Unauthenticated (no/invalid token) | 401 | The client needs to authenticate. |
| Forbidden (no permission) | 403 | Auth is valid, but access is not allowed. |
| NotFound (resource ID does not exist) | 404 | The requested resource is not there (or you choose to hide existence). |
| Conflict (unique constraint, version mismatch) | 409 | The request is well-formed, but it clashes with current state. |
| ValidationFailed (field rules) | 422 | The shape is fine, but business validation fails (email format, min length). |
| RateLimited | 429 | Too many requests in a time window. |
| Internal (unknown error) | 500 | Bug or unexpected failure. |
| Unavailable (dependency down, timeout, maintenance) | 503 | Temporary server-side issue. |
混乱を防ぐための2つの区別:
リトライの指針:
リクエスト ID は API 呼び出し一回を一意に識別する短い値です。クライアントが各レスポンスでそれを見られれば、サポートは「リクエスト ID を送ってください」で正確なログと失敗を見つけられます。
この習慣は成功応答と失敗応答の両方で有益です。
明確なルールを1つ使ってください:クライアントが送ってきた ID はそのまま使う。送ってこなければ新しく作る。
X-Request-Id)。リクエスト ID は3か所に入れてください:
request_id)バッチエンドポイントやバックグラウンドジョブでは親リクエスト ID を保ってください。例:クライアントが200行をアップロードして12行が検証で失敗し、処理をキューに入れる場合、全体の呼び出しに対して1つの request_id を返し、各ジョブやアイテムごとのエラーに parent_request_id を含めます。こうすることで、多数にファンアウトしても「1回のアップロード」を追跡できます。
クライアントは明確で安定したエラー応答を必要とします。あなたのログは煩雑な真実を必要とします。これら2つの世界を分けてください:クライアントには安全なメッセージと公開エラーコードを返し、サーバログには内部の原因、スタック、コンテキストを残します。
エラー応答ごとに構造化されたイベントを1つログに残し、request_id で検索できるようにしてください。
一貫して残すと役立つフィールド:
内部の詳細はサーバログ(または内部のエラーストア)だけに保存してください。クライアントは生のデータベースエラー、クエリテキスト、スタックトレース、プロバイダメッセージを決して見てはいけません。複数サービスを運用しているなら、内部フィールド source(api, db, auth, upstream など)を付けるとトリアージが速くなります。
ノイズの多いエンドポイントやレート制限されたエラーを監視してください。あるエンドポイントが毎分何千回も同じ 429 や 400 を出すなら、ログスパムを避けるためにイベントのサンプリングや重大度を下げつつメトリクスではカウントし続けるなどの対策を取りましょう。
メトリクスはログより早く問題を検出します。HTTP ステータスとエラーコードごとのカウントを追跡し、急増をアラートにしてください。デプロイ後に RATE_LIMITED が 10 倍になれば、ログがサンプリングされていてもすぐに気づけます。
エラーを一貫させる最も簡単な方法は、あちこちで扱うのをやめて小さなパイプラインに集約することです。そのパイプラインがクライアントに見せる内容とログ用に残す内容を決めます。
まずクライアントが依存できる小さなエラーコードセット(例:INVALID_ARGUMENT, NOT_FOUND, UNAUTHORIZED, CONFLICT, INTERNAL)から始めます。これらを、公開可能な安全なフィールド(code、safe message、場合によってはどのフィールドが間違っているか)だけを露出する型付きエラーでラップします。内部の原因は非公開にします。
次に任意のエラーを (statusCode, responseBody) に変換する1つのトランスレータ関数を実装します。ここで型付きエラーを HTTP ステータスにマッピングし、不明なエラーは安全な 500 応答にします。
次にミドルウェアを追加します:
request_id を確実に付与するpanic がクライアントにスタックトレースを投げてはいけません。通常の 500 応答を返し、同じ request_id で完全な panic をログに残してください。
最後にハンドラを変更して、直接レスポンスを書き込むのではなく error を返すようにします。ラッパーがハンドラを呼び、トランスレータを実行して標準フォーマットの JSON を書き込むようにします。
簡潔なチェックリスト:
ゴールデンテストは契約を固定するため重要です。誰かが後でメッセージやステータスコードを変えると、テストで失敗してクライアントが驚く前に検出できます。
顧客レコードを作るエンドポイントを想像してください。
POST /v1/customers に { "email": "[email protected]", "name": "Pat" } のような JSON を送ると、サーバは常に同じエラー形を返し、常に request_id を含めます。
メールがないかフォーマットが不正で、クライアントはフィールドをハイライトできます。
{
"request_id": "req_01HV9N2K6Q7A3W1J9K8B",
"error": {
"code": "VALIDATION_FAILED",
"message": "Some fields need attention.",
"details": {
"fields": {
"email": "must be a valid email address"
}
}
}
}
メールが既に存在する場合。クライアントはサインインを提案するか別のメールを選ぶよう案内できます。
{
"request_id": "req_01HV9N3C2D0F0M3Q7Z9R",
"error": {
"code": "ALREADY_EXISTS",
"message": "A customer with this email already exists."
}
}
依存先がダウンしている場合。クライアントはバックオフして再試行を促し、落ち着いたメッセージを表示できます。
{
"request_id": "req_01HV9N3X8P2J7T4N6C1D",
"error": {
"code": "TEMPORARILY_UNAVAILABLE",
"message": "We could not save your request right now. Please try again."
}
}
1つの契約があればクライアントは一貫して反応できます:
details.fields を使ってフィールドをマークするrequest_id をサポート ID として表示するサポートでは同じ request_id が内部ログで本当の原因を見つける最速パスになります。内部のスタックトレースや DB エラーを露出せずに済みます。
クライアントを苛立たせる最速の方法は推測を強いることです。あるエンドポイントが { "error": "..." } を返し、別が { "message": "..." } を返すと、クライアントは特例の山になり、バグが何週間も隠れます。
繰り返し出る間違い:
code の代わりに人間向けテキストだけを識別子として使う。request_id を付ける(成功時に付けない)ので相関ができない。内部を漏らすのは一番簡単に陥る罠です。ハンドラが手っ取り早さから err.Error() を返すと、制約名やサードパーティのメッセージが本番に露出してしまいます。クライアント向けのメッセージは安全で短く、詳細はログに残してください。
テキストのみで依存するのも徐々に問題になります。クライアントが “email already exists” のような英語文章を解析していると、文言を変えただけで動作が壊れます。安定したエラーコードを使えばメッセージは自由に変更・翻訳できます。
エラーコードは公開契約の一部として扱ってください。変更が必要なら新しいコードを追加し、古いコードはしばらく動作し続けるようにして互換性を保ちます。
最後に、成功でも失敗でも同じ request_id フィールドを含めてください。ユーザーが「動いていたのに壊れた」と言ったとき、その ID が1時間の推測を節約してくれます。
リリース前に一貫性のために簡単に確認してください:
error.code, error.message, request_id)を返す。VALIDATION_FAILED, NOT_FOUND, CONFLICT, UNAUTHORIZED)。ハンドラが未知のコードを返さないようにテストを用意する。request_id を返し、タイムアウトや panic でもログに記録する。その後、いくつかのエンドポイントを手動でサンプルチェックしてください。検証エラー、レコードの欠如、予期しない失敗をトリガーします。もしエンドポイント間で応答が異なる(フィールドが変わる、ステータスがずれる、メッセージが内部を漏らす)なら、機能を増やす前に共有パイプラインを修正してください。
実用的なルール:メッセージが攻撃者を助けたり通常ユーザーを混乱させるなら、それはレスポンスではなくログに置いてください。
API が既に稼働していても、すべてのエンドポイントが従うべきエラー契約(ステータス、安定したエラーコード、安全なメッセージ、request_id)を書き出してください。これがクライアントにとってエラーを予測可能にする最速の方法です。
その後、段階的に移行してください。既存ハンドラを残しつつ、失敗を1つのマッパーに通して公開用の応答形に変換してください。これにより大きなリライトをせずとも一貫性を高め、新しいエンドポイントが別の形式を生み出すのを防げます。
小さなエラーコードカタログを作り、それを API の一部として扱ってください。新しいコードを追加する際はレビューをして:本当に新しいか、名前は分かりやすいか、適切な HTTP ステータスにマップしているかを確認します。
ドリフトを検出するための数件のテストを追加してください:
request_id が含まれている。error.code が存在し、カタログから来ている。error.message は安全で内部の詳細を含まない。もし Go でバックエンドを一から作るなら、契約を早期に固めるのは有益です。例えば、Koder.ai (koder.ai) は計画モードでエラースキーマやコードカタログのような規約を先に定義でき、API が成長してもハンドラを整合させ続けられます。
1つの JSON 形を全てのエラー応答で使ってください。実用的なデフォルトは、トップレベルに request_id を置き、code、message、任意の details を持つ error オブジェクトを返す形です。これによりクライアントは確実に解析して適切に処理できます。
error.message は短くユーザー向けに安全な文にして、実際の原因はサーバーログに残してください。生のデータベースエラー、スタックトレース、内部ホスト名、依存先のメッセージは返さないでください。開発中でも同様に扱うべきです。
機械的な分岐には安定した error.code を使い、HTTP ステータスは大まかなカテゴリを示すために使います。クライアントは ALREADY_EXISTS のような error.code を基準に振る舞いを決め、ステータスは補助情報として扱ってください。
リクエストが解析できない(不正な JSON、型が間違っている)場合は 400 を使ってください。リクエスト自体は構文的に正しいがビジネスルールに反する場合(無効なメール形式、パスワードの短さなど)は 422 を使います。
入力自体は有効だが現在の状態と衝突する(メールが既に使われている、バージョン不一致など)場合は 409 を使います。フィールドレベルの検証エラーで、値を変えれば解決する場合は 422 を使ってください。
Go では小さなセットの型付きエラー(validation, not found, conflict, unauthorized, internal など)を作り、ハンドラはそれらを返すようにします。共通のトランスレータを使って型をステータスと標準 JSON 形に変換すると、応答が一貫します。
全てのレスポンス(成功・失敗ともに)に request_id を返し、サーバの全ログ行にも記録してください。クライアントが問題を報告するとき、その ID があればログ上で正確な実行を追跡できます。
操作が成功したときにだけ 200 を返し、エラーに対しては適切な 4xx/5xx を返してください。200 の中に { "ok": false } のように隠すと、クライアントはボディを解析しなければならず、一貫性が失われます。
一般的には 400, 401, 403, 404, 409, 422 はリトライしてもうまくいかないことが多いのでリトライ不可とし、503(および場合によっては 429 の待機後)はリトライを許容します。POST に対しては冪等キーをサポートすると一時的な失敗時のリトライが安全になります。
“ゴールデン”テストを使って契約を固定してください:ステータス、error.code、request_id の有無を検査します。新しいコードを追加する場合は既存の意味を壊さないようにし、オプションフィールドのみを追加して古いクライアントが無視できるようにします。