Go のエラーハンドリング粒度設計──本番障害で「どのエラーか分からない」を防ぐ
現場で起きる「err != nil」の落とし穴
Go でシステムを運用していると、本番環境で起きた障害対応のとき、ログに記録されたエラーメッセージを見ても「何が起きたのか判断できない」という場面に出くわします。
典型的には、外部 API 呼び出しやデータベース接続などで err != nil をキャッチしたとき、エラーを単に log.Println(err) で記録するだけでは、それが一時的なネットワーク障害なのか、認証失敗なのか、タイムアウトなのかが見分けにくい。結果として、夜中の障害対応で同じログを何度も見返し、実装を確認し、本当に必要な対応が遅れるということが起きやすいのです。
この記事では、Goのエラーハンドリングの粒度設計──つまり「どこまで細かくエラーを分類するか」という判断──が、本番運用にどう影響するかを、実装パターンと運用観点から整理します。
エラーの粒度が曖昧だと何が困るのか
まず、問題を具体的に見てみましょう。下記のようなコードがあるとします。
func FetchUserData(userID string) (*User, error) {
resp, err := http.Get("https://api.example.com/users/" + userID)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var user User
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return nil, err
}
return &user, nil
}
このコードで err != nil が発火した場合、呼び出し側はどうしますか?
user, err := FetchUserData(userID)
if err != nil {
log.Println("Error:", err)
// ここで何をする?リトライ?即座に失敗を返す?ユーザーに通知?
}
ログには「Get https://api.example.com/users/…: i/o timeout」や「unexpected end of JSON input」などが記録されるでしょう。しかし、本番で複数のエラーが並行して起きている状況では、このメッセージだけから「いま何が起きているのか」を素早く判断するのは難しい。
特に以下の場面で判断が遅れやすいです:
- アラート対応: エラーの種類によって対応方法が変わるのに、ログを見るまで分からない
- リトライ戦略: ネットワーク障害なら待つべきだが、認証失敗なら待つ意味がない
- ユーザー影響の範囲: 一部のユーザーだけ影響を受けるのか、全体か
- 根本原因調査: 外部サービス側の問題か、こちら側の設定か
エラー型を定義して分類する
Goで推奨される方法は、カスタムエラー型を定義して、エラーの種類を明示的に分類することです。
type APIError struct {
Code string // "timeout", "unauthorized", "not_found", "invalid_json" など
Message string
Err error // 元のエラー
}
func (e *APIError) Error() string {
return fmt.Sprintf("[%s] %s: %v", e.Code, e.Message, e.Err)
}
func FetchUserData(userID string) (*User, error) {
resp, err := http.Get("https://api.example.com/users/" + userID)
if err != nil {
// ネットワーク関連のエラーを分類
if os.IsTimeout(err) {
return nil, &APIError{
Code: "timeout",
Message: "API request timed out",
Err: err,
}
}
return nil, &APIError{
Code: "network_error",
Message: "Failed to call API",
Err: err,
}
}
defer resp.Body.Close()
// ステータスコードで分類
if resp.StatusCode == http.StatusUnauthorized {
return nil, &APIError{
Code: "unauthorized",
Message: "API authentication failed",
Err: nil,
}
}
if resp.StatusCode == http.StatusNotFound {
return nil, &APIError{
Code: "not_found",
Message: "User not found",
Err: nil,
}
}
var user User
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return nil, &APIError{
Code: "invalid_response",
Message: "Failed to parse API response",
Err: err,
}
}
return &user, nil
}
呼び出し側では、エラー型をアサーションして処理を分ける:
user, err := FetchUserData(userID)
if err != nil {
var apiErr *APIError
if errors.As(err, &apiErr) {
switch apiErr.Code {
case "timeout", "network_error":
// リトライ可能
log.Printf("Retryable error: %v", apiErr)
// リトライロジック
case "unauthorized":
// 設定を確認し、すぐには再試行しない
log.Printf("Auth error: %v", apiErr)
// アラート送信
case "not_found":
// 正常系として扱う可能性もある
log.Printf("Not found: %v", apiErr)
case "invalid_response":
// バグの可能性が高い
log.Printf("Bug detected: %v", apiErr)
}
} else {
// 予期しないエラー
log.Printf("Unexpected error: %v", err)
}
}
粒度設計の判断基準
ここで重要なのは、「どこまで細かく分類するか」という判断です。細かすぎると実装が煩雑になり、粗すぎると本番対応が困難になります。
実務では、以下の観点で粒度を決めるのが現実的です:
1. 呼び出し側の対応が変わるか
同じ対応をするエラーは、同じコードにまとめても構いません。例えば「ネットワークタイムアウト」と「接続リセット」は、どちらもリトライ戦略が同じなら、"network_error" で十分です。
2. 本番アラートの判断基準になるか
「認証失敗」と「一般的なネットワーク障害」は、アラート対応の優先度が異なるなら、分類する価値があります。
3. 監視・メトリクス収集の必要性
エラーの種類ごとに発生頻度を追跡したいなら、分類が必要です。
4. 運用チームが実装を見ずに判断できるか
ログだけから「何をすべきか」が明確に分かることが目標です。
実装のコツと注意点
構造化ログとの組み合わせ
エラー型を定義しても、単純な log.Println() では活かしきれません。構造化ログライブラリ(例:slog や zap)と組み合わせると、検索・集計がしやすくなります。
slog.Error("Failed to fetch user data",
"error_code", apiErr.Code,
"user_id", userID,
"underlying_error", apiErr.Err,
)
これにより、ログ集約サービスで error_code: "timeout" のみを抽出したり、ダッシュボードで種類別のエラー発生数をグラフ化したりできます。
エラーの過度な分類を避ける
「すべての可能性を分類しよう」という思考は危険です。実装が複雑になり、保守負荷が増えます。
- 最初は粗めに分類(「API エラー」「DB エラー」「入力エラー」など)
- 本番運用で「この分類では判断できない」という場面が出たら、細分化する
という段階的なアプローチをお勧めします。
元のエラー情報は保持する
カスタムエラー型を定義するときは、元の error を保持しておきましょう。デバッグ時に詳細情報が必要になります。
// 良い例
return nil, &APIError{
Code: "timeout",
Err: err, // 元のエラーを保持
}
// 避けるべき
return nil, &APIError{
Code: "timeout",
// Err を捨てる
}
誰に向いているか、どんな案件で効果的か
このパターンは以下のような案件で特に効果を発揮します:
- 外部 API 連携が多い: 複数の外部サービスと連携し、障害対応が頻繁に起きる案件
- 24 時間運用: 夜中の障害対応で、実装を確認する時間がない環境
- マイクロサービス: サービス間の通信エラーを細かく分類する必要がある
- チーム開発: 実装者以外が本番対応にあたり、ログだけから判断する必要がある
逆に、小規模な社内ツールや、エラー自体がほとんど起きない案件では、ここまで細かい設計は過剰かもしれません。
検証のポイント
実装投入前に以下を確認しておくと、後々の運用がスムーズです:
- エラー型のマーシャリング:JSON 形式で出力するときに、必要な情報がすべて含まれるか
- ログ集約サービスでの検索性:
error_codeフィールドで確実にフィルタできるか - 既存の監視アラートとの連携:新しいエラー型が既存のアラートルールに反映されるか
- ドキュメント化:チーム内で「どのエラーコードが何を意味するか」が共有されているか
終わりに
Go のエラーハンドリングは、言語の設計思想として「明示的」であることが重視されています。err != nil のシンプルな構文だからこそ、その先の粒度設計が重要になります。
本番運用で「エラーメッセージを見ても何が起きたか分からない」という経験は、多くのエンジニアがしているはずです。その解決策は、複雑な仕組みではなく、エラーの種類を意識的に分類し、呼び出し側が適切に判