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() では活かしきれません。構造化ログライブラリ(例:slogzap)と組み合わせると、検索・集計がしやすくなります。

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 のシンプルな構文だからこそ、その先の粒度設計が重要になります。

本番運用で「エラーメッセージを見ても何が起きたか分からない」という経験は、多くのエンジニアがしているはずです。その解決策は、複雑な仕組みではなく、エラーの種類を意識的に分類し、呼び出し側が適切に判