Goのエラーハンドリングで失敗しない方法—wrappingパターンと構造化ログの組み合わせ

Goのエラーハンドリングで失敗しない方法—wrappingパターンと構造化ログの組み合わせ | mohablog
目次

Goのエラーハンドリングはなぜ難しく感じるのか

Web開発でGoを使い始めると、最初に戸惑うのがエラーハンドリングですね。Pythonのtry-exceptと違い、Goはif err != nilを何度も書くことになる。本番環境で動かしていると、エラーが発生した時点での情報がどこまで保持されているのか、どのレイヤーで何が起きたのかが曖昧になりやすい。

最初、私も安易にreturn errと書いていました。するとAPI呼び出し元には「internal server error」としか返らず、実際のログを見ると「database connection failed」という親切さ0の情報。原因の特定に数時間費やしたこともあります。

この記事では、Go 1.13から使えるerrors.Is/errors.Asを活用したエラーの構造化、エラーメッセージのラッピング、そして構造化ログとの組み合わせで、本番環境で本当に役に立つエラーハンドリングを解説します。

基本:エラーをラップしながら文脈を追加する

アンチパターン—文脈なしのエラー返却

まずはNGなパターンを見てみましょう。

func fetchUser(ctx context.Context, userID string) (*User, error) {
    resp, err := http.Get(fmt.Sprintf("https://api.example.com/users/%s", 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
}

このコードでHTTPタイムアウトが発生した場合、呼び出し元に返ってくるエラーは「context deadline exceeded」というだけ。どのAPI呼び出しで失敗したのか、どのユーザーを取得しようとしていたのかが完全に失われます。

改善1:fmt.Wrapfでエラーに文脈を付ける(Go 1.13以降)

エラーに文脈情報を追加するには、fmt.Errorf%wフォーマットを使います。この方法により、元のエラーを保持しながら新しい情報を追加できます。

import (
    "errors"
    "fmt"
    "net/http"
)

func fetchUser(ctx context.Context, userID string) (*User, error) {
    resp, err := http.Get(fmt.Sprintf("https://api.example.com/users/%s", userID))
    if err != nil {
        return nil, fmt.Errorf("failed to fetch user %s: %w", userID, err)
    }
    defer resp.Body.Close()
    
    var user User
    if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
        return nil, fmt.Errorf("failed to decode user response for %s: %w", userID, err)
    }
    return &user, nil
}
エラーメッセージ例:
failed to fetch user user123: Get "https://api.example.com/users/user123": context deadline exceeded

%wを使うことで、元のエラーはチェーンのように保持されます。つまり、呼び出し元でerrors.Isで元のエラー型を検査できるようになるんですね。

カスタムエラー型でさらに構造化を進める

単純な文字列ラップでは足りない場合、カスタムエラー構造体を定義することで、エラーの種類やステータスコードなどを保持できます。

type APIError struct {
    StatusCode int
    Operation  string  // 「fetch user" など
    Resource   string  // "user123" など
    Err        error   // 元のエラー
}

func (e *APIError) Error() string {
    return fmt.Sprintf("%s failed for %s (status=%d): %v", e.Operation, e.Resource, e.StatusCode, e.Err)
}

func (e *APIError) Unwrap() error {
    return e.Err
}

func fetchUser(ctx context.Context, userID string) (*User, error) {
    resp, err := http.Get(fmt.Sprintf("https://api.example.com/users/%s", userID))
    if err != nil {
        return nil, &APIError{
            Operation: "fetch user",
            Resource:  userID,
            Err:       err,
        }
    }
    
    defer resp.Body.Close()
    
    if resp.StatusCode != http.StatusOK {
        return nil, &APIError{
            StatusCode: resp.StatusCode,
            Operation:  "fetch user",
            Resource:   userID,
            Err:        fmt.Errorf("unexpected status code"),
        }
    }
    
    var user User
    if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
        return nil, &APIError{
            Operation: "decode user response",
            Resource:  userID,
            Err:       err,
        }
    }
    return &user, nil
}

Unwrap()メソッドを実装することで、Go標準のerrors.Is/errors.Asの仕組みと連携します。この詳細は次のセクションで説明します。

errors.IsとUnwrap()でエラー型を検査する

呼び出し元でエラーを条件判定する

APIエラーが発生した時、その原因が「接続タイムアウト」なのか「404 Not Found」なのかで、ハンドリング方法を変える必要があります。ここで活躍するのがerrors.Isです。

user, err := fetchUser(ctx, "user123")
if err != nil {
    var apiErr *APIError
    if errors.As(err, &apiErr) {
        // APIErrorの構造を直接アクセス可能
        if apiErr.StatusCode == http.StatusNotFound {
            // 404の場合は404ページを返す
            return nil, ErrUserNotFound
        }
        if apiErr.StatusCode >= 500 {
            // 5xxエラーはリトライ対象
            log.Printf("Server error, will retry: %v", apiErr)
        }
    }
    
    if errors.Is(err, context.DeadlineExceeded) {
        // タイムアウトの場合
        return nil, ErrTimeout
    }
    
    return nil, err
}

公式ドキュメントを確認すると、errors.Asはエラーチェーンを下っていき、指定した型にマッチするものを探します。一方errors.Isは「このエラーは特定の値か」をチェックする仕組みですね。

複数のエラー型を定義するときのパターン

実際のプロジェクトでは、複数の種類のエラーを定義することが多いです。バリデーションエラー、データベースエラー、認可エラーなど。これらを統一的にハンドルするパターンを示します。

type ErrorKind int

const (
    ErrKindValidation ErrorKind = iota
    ErrKindNotFound
    ErrKindUnauthorized
    ErrKindInternal
)

type DomainError struct {
    Kind      ErrorKind
    Message   string
    Resource  string
    Err       error
}

func (e *DomainError) Error() string {
    if e.Err != nil {
        return fmt.Sprintf("%s: %s (resource=%s): %v", e.kindString(), e.Message, e.Resource, e.Err)
    }
    return fmt.Sprintf("%s: %s (resource=%s)", e.kindString(), e.Message, e.Resource)
}

func (e *DomainError) kindString() string {
    switch e.Kind {
    case ErrKindValidation:
        return "ValidationError"
    case ErrKindNotFound:
        return "NotFoundError"
    case ErrKindUnauthorized:
        return "UnauthorizedError"
    default:
        return "InternalError"
    }
}

func (e *DomainError) Unwrap() error {
    return e.Err
}

// HTTPレスポンスステータスコードに変換
func (e *DomainError) StatusCode() int {
    switch e.Kind {
    case ErrKindValidation:
        return http.StatusBadRequest
    case ErrKindNotFound:
        return http.StatusNotFound
    case ErrKindUnauthorized:
        return http.StatusUnauthorized
    default:
        return http.StatusInternalServerError
    }
}

このように定義すると、HTTPハンドラーで統一的にエラー処理ができます。

構造化ログとエラーハンドリングの統合

slogを使った標準的な構造化ログ

Go 1.21から標準ライブラリにlog/slogが追加されました。エラー情報を単なる文字列ではなく、構造化データとして記録することで、本番環境でのトラブルシューティングが格段に楽になります。

import "log/slog"

func handleUserRequest(w http.ResponseWriter, r *http.Request) {
    userID := r.PathValue("userID")
    
    user, err := fetchUser(r.Context(), userID)
    if err != nil {
        var domainErr *DomainError
        if errors.As(err, &domainErr) {
            // DomainErrorの場合は、エラー情報を属性として記録
            slog.Error("failed to fetch user",
                slog.String("user_id", userID),
                slog.String("error_kind", domainErr.kindString()),
                slog.String("message", domainErr.Message),
                slog.String("error", err.Error()),
            )
            w.WriteHeader(domainErr.StatusCode())
            json.NewEncoder(w).Encode(map[string]string{"error": domainErr.Message})
            return
        }
        
        // 予期しないエラーの場合
        slog.Error("unexpected error",
            slog.String("user_id", userID),
            slog.String("error", err.Error()),
        )
        w.WriteHeader(http.StatusInternalServerError)
        json.NewEncoder(w).Encode(map[string]string{"error": "Internal Server Error"})
        return
    }
    
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(user)
}

slogは出力フォーマットをJSON形式に切り替えられます。これにより、ログ集約ツール(DatadogやCloudWatch)で検索・フィルタリングが容易になりますね。

リクエストコンテキストに追跡IDを埋め込む

複雑な処理で複数のレイヤーを横断する場合、どのリクエストに由来するエラーなのかを把握することが重要です。追跡ID(Trace ID)をリクエストコンテキストに埋め込むパターンです。

import (
    "context"
    "github.com/google/uuid"
)

type contextKey string

const traceIDKey contextKey = "trace_id"

func traceIDMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        traceID := r.Header.Get("X-Trace-ID")
        if traceID == "" {
            traceID = uuid.NewString()
        }
        
        ctx := context.WithValue(r.Context(), traceIDKey, traceID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

func getTraceID(ctx context.Context) string {
    if traceID, ok := ctx.Value(traceIDKey).(string); ok {
        return traceID
    }
    return "unknown"
}

func fetchUser(ctx context.Context, userID string) (*User, error) {
    traceID := getTraceID(ctx)
    
    resp, err := http.Get(fmt.Sprintf("https://api.example.com/users/%s", userID))
    if err != nil {
        slog.Error("http request failed",
            slog.String("trace_id", traceID),
            slog.String("user_id", userID),
            slog.String("error", err.Error()),
        )
        return nil, &DomainError{
            Kind:     ErrKindInternal,
            Message:  "Failed to fetch user",
            Resource: userID,
            Err:      err,
        }
    }
    defer resp.Body.Close()
    
    // 以下省略
    return &User{}, nil
}

Trace IDをログに含めることで、複数サービスにまたがるリクエストの流れを追跡できます。これは本番環境で本当に重宝しますね。

実装時によくあるミスと対策

ミス1:エラーチェーンを無視する

古いGoコードを見ると、fmt.Sprintfでエラーを文字列化してから新しいエラーを作る例がありますね。これはエラーチェーンが失われるので避けるべきです。

// ❌ NG:エラーチェーンが失われる
if err != nil {
    return nil, errors.New(fmt.Sprintf("failed to fetch: %v", err))
}

// ✅ OK:エラーチェーンを保持
if err != nil {
    return nil, fmt.Errorf("failed to fetch: %w", err)
}

この違いは小さく見えますが、呼び出し元でerrors.Isを使った条件判定ができるかどうかで大きく変わります。

ミス2:エラーメッセージのダブりと不一致

複数のレイヤーでそれぞれメッセージを付ける場合、「database connection failed」→「failed to get user from database」→「failed to process request」のようにメッセージが冗長になりやすいです。各レイヤーで何を追加するかを事前に決めておくことが重要です。

// 層的な責務を明確にする
func (r *userRepository) getByID(ctx context.Context, id string) (*User, error) {
    // データベース層:何を取得しようとしたか、何が失敗したかを記録
    row := r.db.QueryRowContext(ctx, "SELECT id, name FROM users WHERE id = $1", id)
    if err := row.Scan(&user.ID, &user.Name); err != nil {
        if err == sql.ErrNoRows {
            return nil, &DomainError{
                Kind:     ErrKindNotFound,
                Message:  "User not found",
                Resource: id,
                Err:      err,
            }
        }
        return nil, fmt.Errorf("query user row: %w", err)
    }
    return user, nil
}

func (s *userService) GetUser(ctx context.Context, id string) (*User, error) {
    // ビジネスロジック層:どのサービス機能が失敗したかを記録
    user, err := s.repo.getByID(ctx, id)
    if err != nil {
        // すでに DomainError ならそのまま返す
        var domainErr *DomainError
        if errors.As(err, &domainErr) {
            return nil, domainErr
        }
        // 予期しないエラーはラップ
        return nil, fmt.Errorf("get user from repository: %w", err)
    }
    return user, nil
}

各層は「自分の責務に関連する情報」だけをエラーに追加し、すでに DomainError のようなドメイン固有のエラーが発生している場合は、それ以上ラップしないというルールです。

ミス3:コンテキストタイムアウト時の不適切なハンドリング

Go開発では context.Context のタイムアウトがよく発生します。これを一般的な「エラー」と同じに扱うと、リトライ可能なのか、ユーザーに「サーバーエラー」と返すべきなのかが曖昧になります。

func fetchUser(ctx context.Context, userID string) (*User, error) {
    resp, err := http.Get(fmt.Sprintf("https://api.example.com/users/%s", userID))
    if err != nil {
        // コンテキストキャンセルとタイムアウトを区別
        if errors.Is(err, context.Canceled) {
            // クライアント側がキャンセルした(リトライ不要)
            return nil, fmt.Errorf("request canceled: %w", err)
        }
        if errors.Is(err, context.DeadlineExceeded) {
            // サーバー側でタイムアウト(リトライの余地あり)
            return nil, &DomainError{
                Kind:     ErrKindInternal,
                Message:  "Request timeout",
                Resource: userID,
                Err:      err,
            }
        }
        return nil, fmt.Errorf("http request failed: %w", err)
    }
    defer resp.Body.Close()
    // 以下省略
    return &User{}, nil
}

テストでのエラーハンドリング検証

エラー型の検証をテストに含める

単にエラーが発生するかだけでなく、正しい種類のエラーが発生しているかを検証することが重要ですね。

func TestFetchUserNotFound(t *testing.T) {
    ctx := context.Background()
    
    // モック設定:404を返すようにする
    // (実装詳細は省略)
    
    _, err := fetchUser(ctx, "nonexistent")
    if err == nil {
        t.Fatal("expected error, got nil")
    }
    
    var domainErr *DomainError
    if !errors.As(err, &domainErr) {
        t.Fatalf("expected DomainError, got %T: %v", err, err)
    }
    
    if domainErr.Kind != ErrKindNotFound {
        t.Errorf("expected ErrKindNotFound, got %v", domainErr.Kind)
    }
    
    if domainErr.StatusCode() != http.StatusNotFound {
        t.Errorf("expected status 404, got %d", domainErr.StatusCode())
    }
}

公開ドキュメントでerrors.Aserrors.Isの使い分けを確認するのが最適です。特にテストで複合的なエラーシナリオをカバーすることで、本番環境でのトラブルシューティング時間が大幅に短縮されます。

実装例:完全なエラーハンドリングパターン

最後に、ここまでのパターンを組み合わせた完全な実装例を示します。

package main

import (
    "context"
    "encoding/json"
    "errors"
    "fmt"
    "log/slog"
    "net/http"
    "os"
)

type ErrorKind int

const (
    ErrKindValidation ErrorKind = iota
    ErrKindNotFound
    ErrKindUnauthorized
    ErrKindInternal
)

type DomainError struct {
    Kind      ErrorKind
    Message   string
    Resource  string
    Err       error
}

func (e *DomainError) Error() string {
    if e.Err != nil {
        return fmt.Sprintf("%s: %s: %v", e.kindString(), e.Message, e.Err)
    }
    return fmt.Sprintf("%s: %s", e.kindString(), e.Message)
}

func (e *DomainError) kindString() string {
    switch e.Kind {
    case ErrKindValidation:
        return "ValidationError"
    case ErrKindNotFound:
        return "NotFoundError"
    case ErrKindUnauthorized:
        return "UnauthorizedError"
    default:
        return "InternalError"
    }
}

func (e *DomainError) Unwrap() error {
    return e.Err
}

func (e *DomainError) StatusCode() int {
    switch e.Kind {
    case ErrKindValidation:
        return http.StatusBadRequest
    case ErrKindNotFound:
        return http.StatusNotFound
    case ErrKindUnauthorized:
        return http.StatusUnauthorized
    default:
        return http.StatusInternalServerError
    }
}

type User struct {
    ID   string `json:"id"`
    Name string `json:"name"`
}

func fetchUser(ctx context.Context, userID string) (*User, error) {
    if userID == "" {
        return nil, &DomainError{
            Kind:    ErrKindValidation,
            Message: "User ID cannot be empty",
        }
    }
    
    // このセクションはモック化可能にするのが本来ですが、説明の都合上単純化
    if userID == "404" {
        return nil, &DomainError{
            Kind:     ErrKindNotFound,
            Message:  "User not found",
            Resource: userID,
        }
    }
    
    return &User{ID: userID, Name: "John Doe"}, nil
}

func handleUser(w http.ResponseWriter, r *http.Request) {
    userID := r.PathValue("userID")
    
    user, err := fetchUser(r.Context(), userID)
    if err != nil {
        var domainErr *DomainError
        if errors.As(err, &domainErr) {
            slog.Error("domain error occurred",
                slog.String("user_id", userID),
                slog.String("error_kind", domainErr.kindString()),
                slog.String("message", domainErr.Message),
            )
            w.WriteHeader(domainErr.StatusCode())
            json.NewEncoder(w).Encode(map[string]string{"error": domainErr.Message})
            return
        }
        
        slog.Error("unexpected error",
            slog.String("user_id", userID),
            slog.String("error", err.Error()),
        )
        w.WriteHeader(http.StatusInternalServerError)
        json.NewEncoder(w).Encode(map[string]string{"error": "Internal Server Error"})
        return
    }
    
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(user)
}

func main() {
    handler := slog.NewJSONHandler(os.Stdout, nil)
    slog.SetDefault(slog.New(handler))
    
    mux := http.NewServeMux()
    mux.HandleFunc("GET /users/{userID}", handleUser)
    
    slog.Info("server starting", slog.String("addr", ":8080"))
    if err := http.ListenAndServe(":8080", mux); err != nil {
        slog.Error("server error", slog.String("error", err.Error()))
    }
}
実行例:
$ curl http://localhost:8080/users/404
{"error":"User not found"}

ログ出力(JSON形式):
{"time":"2024-01-15T10:30:45.123Z","level":"ERROR","msg":"domain error occurred","user_id":"404","error_kind":"NotFoundError","message":"User not found"}

まとめ

  • fmt.Errorf("%w", err)を使ってエラーをラップし、エラーチェーンを保持することが第一歩
  • カスタムエラー型とUnwrap()メソッドで、より詳細な情報(ステータスコードなど)を構造化して保持できる
  • errors.Is/errors.Asでエラー型を検査し、適切な処理分岐を実装する
  • slogで構造化ログを記録し、ログ集約ツールでの検索・分析を効率化する
  • Trace IDをコンテキストに埋め込むことで、複雑なリクエストフローの追跡が可能になる
  • 各レイヤーで追加する情報を明確に定義し、エラーメッセージの冗長性を避ける
  • context.Canceledとcontext.DeadlineExceededを区別して、リトライ判定に活かす
  • テストでエラー型の検証を含め、本番環境での信頼性を高める

よくある質問(FAQ)

Q. errors.Isとerrors.Asの使い分けは?

errors.Isは「このエラーが特定の値か」を判定するもので、主にcontext.Canceledcontext.DeadlineExceededなどの定義済みエラーを検査します。一方、errors.Asは「このエラーチェーンのどこかに特定の型のエラーがあるか」を検査し、その型の詳細情報にアクセスする時に使います。カスタムエラー型を作ったら、大抵はAsを使うと覚えておくとよいですね。

Q. 古いコードでerrors.New(fmt.Sprintf(...))を見かけるのはなぜですか?

Go 1.13より前は%wフォーマットが存在せず、エラーチェーンの仕組みもありませんでした。そのため、エラーを文字列化して新しいエラーを作るしかなかった時代の名残です。新規プロジェクトではもちろんfmt.Errorf("%w", err)を使うべきです。

Q. 本番環境でログレベルをINFOではなくWARNに設定した場合、エラーは記録されますか?

slogではERRORレベルはWARNより上位なので、ログレベルがWARN以上に設定されていればエラーログは記録されます。ただし、INFOレベルのログは出力されません。本番では通常ERROR以上を記録し、開発環境ではDEBUGまで記録する設定にするのが一般的ですね。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次