はじめに
Goのエラー処理はシンプルですが、実務では次の理由で事故りやすいです。
- 返すだけで情報が足りず、ログを見ても原因が分からない
- 文字列比較に寄って、呼び出し元が分岐できない
- ラップのしすぎで、結局どこで失敗したのか追えない
この記事は構文の説明よりも「どう設計して運用で回すか」に寄せます。
先に結論 まず固定する方針
- 呼び出し元が分岐したいものは sentinel か typed error にする
- それ以外は fmt.Errorf で文脈を足してラップする
- 比較は errors.Is、取り出しは errors.As を使う
- ログに出す情報とエラーに含める情報を分ける
いつ sentinel error を使うか
sentinel error は「呼び出し元が明確に分岐したい」場合に使います。
- 例
- NotFound
- PermissionDenied
- Conflict
- RateLimit
安易に増やすと、分岐が増えて保守がつらくなります。
いつ typed error を使うか
typed error は「追加情報が必要で、呼び出し元が取り出したい」場合に向きます。
- 例
- フィールド名、バリデーション種別
- 下流サービス名、HTTPステータス
- リトライ可能かどうか
実装テンプレ
sentinel error
package app
import "errors"
var ErrNotFound = errors.New("not found")
呼び出し側
if errors.Is(err, app.ErrNotFound) {
// 404
}
typed error
package app
import "fmt"
type ValidationError struct {
Field string
Code string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed: field=%s code=%s", e.Field, e.Code)
}
呼び出し側
var ve *app.ValidationError
if errors.As(err, &ve) {
// ve.Field, ve.Code を使ってレスポンスを組み立てる
}
ラップ(文脈を足す)
if err != nil {
return fmt.Errorf("get user: id=%s: %w", id, err)
}
文脈を足すと調査が速くなります。
ただし、秘密情報を入れないように注意します。
ログ設計との分担
エラー文字列に全部を入れると、冗長になりすぎます。
- エラー
- 文脈(どの処理で失敗したか)
- 呼び出し元が分岐できる種類
- ログ
- requestId、traceId
- 引数の一部(個人情報はマスク)
- 下流のレスポンス要約
よくある落とし穴
- errors.Is を使わずに == で比較する
- ラップすると一致しない
- typed error の値型とポインタ型を混ぜて As が通らない
- どちらで返すか方針を固定する
- エラー文に個人情報やトークンを入れる
- ログ共有で事故る
レビュー用チェックリスト
- 文字列比較で分岐していない
- 失敗時に文脈が追加されている
- 呼び出し元が分岐したい種類が明確(sentinel または typed)
- 個人情報や秘密情報がエラー文に含まれない
- errors.Is / errors.As の使い分けができている