Goエラーハンドリングベストプラクティス:ラッピングからリカバリまでの6つのプロダクションパターン

编程语言

エラー情報の消失がパニックカスケードと出会う時:Goエラーハンドリングの至難の時

午前2時、決済サービスのエラー率が30%に急上昇。ログには "database error: sql: no rows in result set" が溢れているが、どのエンドポイントで、どのSQLクエリで、どのビジネスシナリオで発生したのか全く分からない。さらに悪いことに、ゴルーチン内でキャッチされなかったパニックがHTTPサービス全体を502エラーに陥らせた。4時間の調査の結果、根本原因が判明:エラーが層ごとに飲み込まれ、コンテキスト情報が完全に失われ、パニックにリカバリがなく、監視アラートが無意味だった。

これは決して稀なケースではない。Goの if err != nil はシンプルだ——関数呼び出しのたびにチェックするだけ——しかし、プロダクションのエラーハンドリングはnilチェックだけでは終わらない。エラーチェーンの保持、型付きエラーの実装、パニックのグレースフルリカバリ、エラーミドルウェアチェーンの構築、オブザーバビリティの統合が必要だ。本記事では6つのプロダクション級エラーハンドリングパターンを解説し、堅牢なGoエラーハンドリングシステムの構築を支援する。


主要な学び

  • エラーラッピングfmt.Errorf("%w", err) で完全なエラーチェーンを保持し、文字列連結でコンテキストを失わない
  • カスタムエラー型:センチネルエラー + カスタム構造体でビジネスセマンティクスを持つエラー分類を実現
  • errors.Is/As:文字列マッチングに代わり、型安全なエラー検査でネストされたエラーチェーンに対応
  • パニックリカバリ:HTTPミドルウェアとゴルーチン入口でパニックをリカバリし、カスケード障害を防止
  • エラーミドルウェアチェーン:ログ、メトリクス、トレーシングをエラーハンドリングフローに統合
  • プロダクションオブザーバビリティ:OpenTelemetryでエラーを分散トレーシングとアラート体系に統合

目次

  1. エラーハンドリングアーキテクチャ概要
  2. パターン1:Error Wrapping with fmt.Errorf and %w verb
  3. パターン2:Custom Error Types with Sentinel Errors
  4. パターン3:errors.Is / errors.As for Error Inspection
  5. パターン4:Panic Recovery Middleware
  6. パターン5:Error Middleware Chain
  7. パターン6:Production Error Observability with OpenTelemetry
  8. 5つのよくある落とし穴と解決策
  9. 10のよくあるエラートラブルシューティング
  10. 高度な最適化テクニック
  11. 比較分析
  12. おすすめオンラインツール
  13. まとめ

エラーハンドリングアーキテクチャ概要

┌─────────────────────────────────────────────────────────────┐
│                    HTTP Request                              │
└──────────────────────────┬──────────────────────────────────┘
                           │
                           ▼
┌──────────────────────────────────────────────────────────────┐
│  Middleware Chain                                             │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────┐    │
│  │ Recovery │→│  Logging │→│ Metrics  │→│ Tracing  │    │
│  └──────────┘  └──────────┘  └──────────┘  └──────────┘    │
└──────────────────────────┬───────────────────────────────────┘
                           │
                           ▼
┌──────────────────────────────────────────────────────────────┐
│  Business Layer                                              │
│  ┌─────────────┐  ┌──────────────┐  ┌──────────────────┐   │
│  │ fmt.Errorf  │  │ Custom Error │  │ errors.Is/As    │   │
│  │  (%w wrap)  │  │  (sentinel)  │  │  (inspection)   │   │
│  └─────────────┘  └──────────────┘  └──────────────────┘   │
└──────────────────────────┬───────────────────────────────────┘
                           │
                           ▼
┌──────────────────────────────────────────────────────────────┐
│  Observability Layer                                         │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────────┐  │
│  │  OpenTelemetry│  │   Alerting   │  │   Dashboard     │  │
│  │    Tracing    │  │   (PagerDuty)│  │   (Grafana)     │  │
│  └──────────────┘  └──────────────┘  └──────────────────┘  │
└──────────────────────────────────────────────────────────────┘

パターン1:Error Wrapping with fmt.Errorf and %w verb

エラーラッピングはGoエラーハンドリングの基石。核心思想:%w 動詞でエラーをラッピングし、完全なエラーチェーンを保持する。文字列連結で元のエラー情報を失わない。

アンチパターン:文字列連結でコンテキストを消失

func getUser(id int) (*User, error) {
    row := db.QueryRow("SELECT * FROM users WHERE id = ?", id)
    var u User
    if err := row.Scan(&u.ID, &u.Name, &u.Email); err != nil {
        return nil, fmt.Errorf("scan user failed: %v", err)
    }
    return &u, nil
}

%v はエラーを文字列に変換する——元の sql: no rows in result set が検査不可能なプレーンテキストになる。呼び出し側は errors.Iserrors.As でエラータイプを判定できない。

正しいアプローチ:%w でエラーチェーンを保持

package repository

import (
    "database/sql"
    "fmt"
)

type User struct {
    ID    int
    Name  string
    Email string
}

func GetUser(id int) (*User, error) {
    row := db.QueryRow("SELECT * FROM users WHERE id = ?", id)
    var u User
    if err := row.Scan(&u.ID, &u.Name, &u.Email); err != nil {
        return nil, fmt.Errorf("get user id=%d: %w", id, err)
    }
    return &u, nil
}

func HandleGetUser(id int) {
    user, err := GetUser(id)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            log.Printf("user not found: id=%d", id)
            return
        }
        log.Printf("unexpected error: %v", err)
    }
    _ = user
}

多層ラッピングのエラーチェーン

package service

import (
    "fmt"
    "myapp/repository"
)

func ProcessOrder(orderID int) error {
    user, err := repository.GetUser(orderID)
    if err != nil {
        return fmt.Errorf("process order %d: %w", orderID, err)
    }
    _ = user
    return nil
}

func HandleOrder(orderID int) {
    if err := ProcessOrder(orderID); err != nil {
        var sqlErr *sqlite.Error
        if errors.As(err, &sqlErr) {
            log.Printf("database error: %v", sqlErr)
        }
    }
}

重要ルール

  • 常に %v ではなく %w でエラーをラッピングする
  • ラッピング時にビジネスコンテキストを追加(関数名、パラメータ、操作の説明)
  • エラーチェーンは最大3-4層まで。それ以上は抽象化レベルの問題
  • 境界層でのみ1回ラッピングし、複数層での重複ラッピングを避ける

パターン2:Custom Error Types with Sentinel Errors

カスタムエラー型はエラーにビジネスセマンティクスを与える。核心思想:センチネルエラーで予測可能なエラー状態を定義し、カスタム構造体で構造化コンテキストを運ぶ

センチネルエラー:予測可能なエラー定数

package apperr

import "errors"

var (
    ErrNotFound       = errors.New("resource not found")
    ErrUnauthorized   = errors.New("unauthorized access")
    ErrForbidden      = errors.New("forbidden operation")
    ErrConflict       = errors.New("resource conflict")
    ErrRateLimited    = errors.New("rate limit exceeded")
    ErrValidation     = errors.New("validation failed")
    ErrInternal       = errors.New("internal server error")
)

カスタムエラー型:構造化コンテキストの運搬

package apperr

import (
    "fmt"
    "time"
)

type DomainError struct {
    Code      string
    Message   string
    Domain    string
    Timestamp time.Time
    Err       error
}

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

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

func NewDomainError(domain, code, message string, err error) *DomainError {
    return &DomainError{
        Code:      code,
        Message:   message,
        Domain:    domain,
        Timestamp: time.Now(),
        Err:       err,
    }
}

ビジネス層でのカスタムエラー使用

package order

import (
    "apperr"
    "fmt"
)

type OrderService struct {
    repo OrderRepository
}

func (s *OrderService) CreateOrder(req CreateOrderRequest) (*Order, error) {
    if err := validateOrder(req); err != nil {
        return nil, apperr.NewDomainError(
            "order",
            "VALIDATION_ERROR",
            fmt.Sprintf("invalid order request: user_id=%d", req.UserID),
            err,
        )
    }

    existing, err := s.repo.FindByUserAndProduct(req.UserID, req.ProductID)
    if err != nil {
        return nil, apperr.NewDomainError(
            "order",
            "DB_ERROR",
            "failed to check existing order",
            err,
        )
    }
    if existing != nil {
        return nil, apperr.NewDomainError(
            "order",
            "CONFLICT",
            fmt.Sprintf("duplicate order: user=%d product=%d", req.UserID, req.ProductID),
            apperr.ErrConflict,
        )
    }

    order := &Order{
        UserID:    req.UserID,
        ProductID: req.ProductID,
        Quantity:  req.Quantity,
    }
    if err := s.repo.Save(order); err != nil {
        return nil, apperr.NewDomainError(
            "order",
            "DB_ERROR",
            "failed to save order",
            err,
        )
    }
    return order, nil
}

重要ルール

  • センチネルエラーは errors.New で定義し、命名は Err で始める
  • カスタムエラー型は Error()Unwrap() メソッドを実装する必要がある
  • エラーコードは大文字スネークケース(VALIDATION_ERROR)で、ログ検索に便利
  • 各ビジネスドメインは独自のエラーコード名前空間を定義し、衝突を避ける

パターン3:errors.Is / errors.As for Error Inspection

エラー検査はエラーハンドリングの中核操作。核心思想:errors.Is でエラー値をチェックし、errors.As でエラータイプを抽出する。脆弱な文字列マッチングに取って代わる。

errors.Is:エラーチェーン内の特定エラー値をチェック

package handler

import (
    "apperr"
    "database/sql"
    "errors"
    "net/http"
)

func GetUserHandler(w http.ResponseWriter, r *http.Request) {
    id := r.URL.Query().Get("id")

    user, err := userService.GetUser(id)
    if err != nil {
        switch {
        case errors.Is(err, sql.ErrNoRows):
            http.Error(w, "user not found", http.StatusNotFound)
        case errors.Is(err, apperr.ErrUnauthorized):
            http.Error(w, "unauthorized", http.StatusUnauthorized)
        case errors.Is(err, apperr.ErrRateLimited):
            http.Error(w, "rate limited", http.StatusTooManyRequests)
        default:
            http.Error(w, "internal error", http.StatusInternalServerError)
        }
        return
    }

    writeJSON(w, http.StatusOK, user)
}

errors.As:エラーチェーンから特定タイプを抽出

package handler

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

func CreateOrderHandler(w http.ResponseWriter, r *http.Request) {
    var req CreateOrderRequest
    if err := decodeJSON(r, &req); err != nil {
        http.Error(w, "bad request", http.StatusBadRequest)
        return
    }

    order, err := orderService.CreateOrder(req)
    if err != nil {
        var domainErr *apperr.DomainError
        if errors.As(err, &domainErr) {
            switch domainErr.Code {
            case "VALIDATION_ERROR":
                http.Error(w, domainErr.Message, http.StatusBadRequest)
            case "CONFLICT":
                http.Error(w, domainErr.Message, http.StatusConflict)
            case "DB_ERROR":
                log.Printf("database error: %v", err)
                http.Error(w, "internal error", http.StatusInternalServerError)
            default:
                http.Error(w, "internal error", http.StatusInternalServerError)
            }
            return
        }

        log.Printf("unhandled error: %v", err)
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }

    writeJSON(w, http.StatusCreated, order)
}

よくある間違い:文字列マッチングによるエラー検査

// ❌ 誤り:文字列マッチングは脆弱で信頼性が低い
if strings.Contains(err.Error(), "not found") {
    // ...
}

// ✅ 正しい:errors.Is でエラー値をチェック
if errors.Is(err, apperr.ErrNotFound) {
    // ...
}

// ❌ 誤り:型アサーションはエラーチェーンを走査しない
if de, ok := err.(*apperr.DomainError); ok {
    // err がラッピングされている場合、ok=false になる
}

// ✅ 正しい:errors.As でエラーチェーンを走査
var de *apperr.DomainError
if errors.As(err, &de) {
    // err が多層にラッピングされていても正しく抽出できる
}

重要ルール

  • err.Error() で文字列マッチングを絶対にしない
  • errors.Is はセンチネルエラーのチェック用、errors.As はカスタムエラータイプの抽出用
  • errors.As の第2引数はターゲットタイプへのポインタでなければならない
  • HTTPハンドラ層でエラーからステータスコードへのマッピングを統一的に行い、ビジネス層はエラーを返すだけ

パターン4:Panic Recovery Middleware

パニックはGoの「核兵器」——滅多に使わないが、防御手段は必須。核心思想:HTTPミドルウェアとゴルーチン入口でパニックをリカバリし、カスケード障害を防止する

HTTPサーバーのパニックリカバリ

package middleware

import (
    "log"
    "net/http"
    "runtime/debug"
)

func Recovery(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rec := recover(); rec != nil {
                stack := debug.Stack()
                log.Printf(
                    "[PANIC] path=%s method=%s error=%v\n%s",
                    r.URL.Path, r.Method, rec, stack,
                )
                http.Error(w, "internal server error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

ゴルーチンのパニックリカバリ

package goroutine

import (
    "log"
    "runtime/debug"
)

func SafeGo(fn func(), panicHandler func(recover any, stack []byte)) {
    go func() {
        defer func() {
            if rec := recover(); rec != nil {
                stack := debug.Stack()
                log.Printf("[GOROUTINE PANIC] recovered=%v\n%s", rec, stack)
                if panicHandler != nil {
                    panicHandler(rec, stack)
                }
            }
        }()
        fn()
    }()
}

エラーチャネル付きゴルーチンリカバリ

package worker

import (
    "runtime/debug"
)

type PanicError struct {
    Recover any
    Stack   []byte
}

func (e *PanicError) Error() string {
    return "goroutine panic recovered"
}

func SafeGoWithErr(fn func() error, errCh chan<- error) {
    go func() {
        defer func() {
            if rec := recover(); rec != nil {
                errCh <- &PanicError{
                    Recover: rec,
                    Stack:   debug.Stack(),
                }
            }
        }()
        if err := fn(); err != nil {
            errCh <- err
        }
    }()
}

func ProcessBatch(items []Item) []error {
    var errs []error
    errCh := make(chan error, len(items))

    for _, item := range items {
        SafeGoWithErr(func() error {
            return processItem(item)
        }, errCh)
    }

    for i := 0; i < len(items); i++ {
        if err := <-errCh; err != nil {
            errs = append(errs, err)
        }
    }
    return errs
}

Ginフレームワークのリカバリミドルウェア

package middleware

import (
    "log"
    "net/http"
    "runtime/debug"

    "github.com/gin-gonic/gin"
)

func GinRecovery() gin.HandlerFunc {
    return func(c *gin.Context) {
        defer func() {
            if rec := recover(); rec != nil {
                stack := debug.Stack()
                log.Printf(
                    "[PANIC] path=%s method=%s client_ip=%s error=%v\n%s",
                    c.Request.URL.Path,
                    c.Request.Method,
                    c.ClientIP(),
                    rec,
                    stack,
                )
                c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
                    "error": "internal server error",
                })
            }
        }()
        c.Next()
    }
}

重要ルール

  • HTTPサーバーは最外層ミドルウェアにリカバリを必ず追加する
  • すべてのゴルーチン入口にリカバリが必要。さもなければパニックがプロセス全体を終了させる
  • リカバリ後は必ず完全なスタックトレースを記録する。さもなければ問題を特定できない
  • リカバリ後はリクエストの処理を継続せず、500を返す
  • 標準ライブラリのパニック(mapの並行書き込みなど)はリカバリしない

パターン5:Error Middleware Chain

エラーミドルウェアチェーンは横断的関心事をエラーハンドリングに統合する。核心思想:ログ、メトリクス、トレーシングをビジネスコードに散らさず、ミドルウェアチェーンで統一的に処理する

エラーミドルウェアアーキテクチャ

Request → Recovery → Logging → Metrics → Tracing → Handler → Response
              │          │         │          │
              ▼          ▼         ▼          ▼
          log panic  log error  emit counter  span error

完全なエラーミドルウェアチェーン実装

package middleware

import (
    "log"
    "net/http"
    "runtime/debug"
    "time"
)

type ResponseRecorder struct {
    http.ResponseWriter
    StatusCode int
    Body       []byte
}

func (r *ResponseRecorder) WriteHeader(code int) {
    r.StatusCode = code
    r.ResponseWriter.WriteHeader(code)
}

func (r *ResponseRecorder) Write(b []byte) (int, error) {
    r.Body = append(r.Body, b...)
    return r.ResponseWriter.Write(b)
}

func ErrorChain(next http.Handler) http.Handler {
    return Recovery(
        Logging(
            Metrics(
                Tracing(next),
            ),
        ),
    )
}

func Recovery(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rec := recover(); rec != nil {
                log.Printf("[PANIC] path=%s error=%v\n%s", r.URL.Path, rec, debug.Stack())
                http.Error(w, "internal server error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

func Logging(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        rec := &ResponseRecorder{ResponseWriter: w, StatusCode: http.StatusOK}

        next.ServeHTTP(rec, r)

        duration := time.Since(start)
        if rec.StatusCode >= 400 {
            log.Printf(
                "[ERROR] method=%s path=%s status=%d duration=%s",
                r.Method, r.URL.Path, rec.StatusCode, duration,
            )
        } else {
            log.Printf(
                "[INFO] method=%s path=%s status=%d duration=%s",
                r.Method, r.URL.Path, rec.StatusCode, duration,
            )
        }
    })
}

func Metrics(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        rec := &ResponseRecorder{ResponseWriter: w, StatusCode: http.StatusOK}
        next.ServeHTTP(rec, r)

        statusCode := rec.StatusCode
        if statusCode >= 500 {
            errorCounter.WithLabelValues(r.URL.Path, "5xx").Inc()
        } else if statusCode >= 400 {
            errorCounter.WithLabelValues(r.URL.Path, "4xx").Inc()
        }
        requestDuration.WithLabelValues(r.URL.Path).Observe(float64(statusCode))
    })
}

func Tracing(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ctx, span := tracer.Start(r.Context(), r.URL.Path)
        defer span.End()

        rec := &ResponseRecorder{ResponseWriter: w, StatusCode: http.StatusOK}
        next.ServeHTTP(rec, r.WithContext(ctx))

        if rec.StatusCode >= 400 {
            span.SetAttributes(attribute.Int("http.status_code", rec.StatusCode))
            span.SetStatus(codes.Error, http.StatusText(rec.StatusCode))
        }
    })
}

関数オプションベースのエラーミドルウェア

package middleware

import (
    "log"
    "net/http"
)

type ErrorMiddlewareOption struct {
    LogErrors   bool
    EmitMetrics bool
    TraceErrors bool
    OnPanic     func(recover any, stack []byte)
}

func ErrorMiddleware(opts ErrorMiddlewareOption) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            defer func() {
                if rec := recover(); rec != nil {
                    stack := debug.Stack()
                    if opts.OnPanic != nil {
                        opts.OnPanic(rec, stack)
                    }
                    log.Printf("[PANIC] %v\n%s", rec, stack)
                    http.Error(w, "internal server error", http.StatusInternalServerError)
                }
            }()

            rec := &ResponseRecorder{ResponseWriter: w, StatusCode: http.StatusOK}
            next.ServeHTTP(rec, r)

            if rec.StatusCode >= 400 {
                if opts.LogErrors {
                    log.Printf("[ERROR] path=%s status=%d", r.URL.Path, rec.StatusCode)
                }
                if opts.EmitMetrics {
                    errorCounter.WithLabelValues(r.URL.Path).Inc()
                }
                if opts.TraceErrors {
                    span := trace.SpanFromContext(r.Context())
                    span.SetStatus(codes.Error, http.StatusText(rec.StatusCode))
                }
            }
        })
    }
}

重要ルール

  • Recoveryは最外層に配置し、すべてのパニックをキャッチする
  • Loggingはリクエストコンテキスト(パス、メソッド、ステータスコード、所要時間)を記録する
  • Metricsはパスとステータスコード別にエラー率を分類統計する
  • Tracingはエラーステータスをスパンに書き込み、分散トレーシングに活用する
  • ミドルウェア順序:Recovery → Logging → Metrics → Tracing → Handler

パターン6:Production Error Observability with OpenTelemetry

プロダクションオブザーバビリティはエラーハンドリングのラストマイル。核心思想:OpenTelemetryでエラーを分散トレーシング、メトリクス、ログの3本柱に統合し、検出から特定までのフルチェーンの可視性を実現する。

OpenTelemetryエラートレーシング統合

package telemetry

import (
    "context"
    "fmt"

    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/attribute"
    "go.opentelemetry.io/otel/codes"
    "go.opentelemetry.io/otel/trace"
)

func RecordError(ctx context.Context, err error, attrs ...attribute.KeyValue) {
    span := trace.SpanFromContext(ctx)
    if !span.IsRecording() {
        return
    }

    span.SetStatus(codes.Error, err.Error())
    span.SetAttributes(attrs...)
    span.RecordError(err, trace.WithAttributes(attrs...))
}

func WrapWithSpan(ctx context.Context, operation string, fn func(ctx context.Context) error) error {
    ctx, span := otel.Tracer("app").Start(ctx, operation)
    defer span.End()

    if err := fn(ctx); err != nil {
        RecordError(ctx, err,
            attribute.String("operation", operation),
        )
        return err
    }
    return nil
}

エラートレーシング付きリポジトリ層

package repository

import (
    "context"
    "database/sql"
    "fmt"

    "apperr"
    "telemetry"

    "go.opentelemetry.io/otel/attribute"
)

type UserRepository struct {
    db *sql.DB
}

func (r *UserRepository) GetUser(ctx context.Context, id int) (*User, error) {
    ctx, span := otel.Tracer("repository").Start(ctx, "UserRepository.GetUser")
    defer span.End()

    span.SetAttributes(attribute.Int("user.id", id))

    row := r.db.QueryRowContext(ctx, "SELECT id, name, email FROM users WHERE id = ?", id)
    var u User
    if err := row.Scan(&u.ID, &u.Name, &u.Email); err != nil {
        if err == sql.ErrNoRows {
            span.SetAttributes(attribute.String("error.type", "not_found"))
            return nil, fmt.Errorf("get user id=%d: %w", id, apperr.ErrNotFound)
        }
        telemetry.RecordError(ctx, err,
            attribute.String("db.operation", "SELECT"),
            attribute.Int("user.id", id),
        )
        return nil, fmt.Errorf("get user id=%d: %w", id, err)
    }

    span.SetAttributes(attribute.String("user.name", u.Name))
    return &u, nil
}

エラーメトリクス収集

package metrics

import (
    "go.opentelemetry.io/otel/metric"
)

var (
    errorCounter metric.Int64Counter
    errorLatency metric.Float64Histogram
)

func InitMetrics(meter metric.Meter) {
    errorCounter, _ = meter.Int64Counter(
        "app.errors.total",
        metric.WithDescription("Total number of errors"),
    )
    errorLatency, _ = meter.Float64Histogram(
        "app.errors.latency_seconds",
        metric.WithDescription("Error handling latency"),
    )
}

func RecordErrorMetric(domain, code string) {
    errorCounter.Add(context.Background(), 1,
        metric.WithAttributes(
            attribute.String("domain", domain),
            attribute.String("code", code),
        ),
    )
}

エラーアラートルール

groups:
  - name: error_alerts
    rules:
      - alert: HighErrorRate
        expr: |
          sum(rate(app_errors_total{code=~"5.."}[5m]))
          /
          sum(rate(http_requests_total[5m]))
          > 0.05
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "Error rate exceeds 5%"
          description: "5xx error rate is {{ $value | humanizePercentage }}"

      - alert: PanicDetected
        expr: increase(app_errors_total{code="PANIC"}[1m]) > 0
        for: 0m
        labels:
          severity: critical
        annotations:
          summary: "Panic recovered in production"
          description: "{{ $value }} panic(s) detected in the last minute"

重要ルール

  • すべてのエラーは少なくとも1つのトレーシングスパンに記録する
  • エラーメトリクスはドメインとコードで分類し、集計分析に役立てる
  • アラートルールは4xx(クライアントエラー)と5xx(サーバーエラー)を区別する
  • パニックアラートは即座に発火(for: 0m)、蓄積期間を設けない
  • ログ、メトリクス、トレースは同じtrace_idで関連付ける

5つのよくある落とし穴と解決策

落とし穴1:エラーの飲み込み

// ❌ 誤り:戻り値の無視
_, _ = io.Copy(dst, src)

// ✅ 正しい:エラーをチェック
written, err := io.Copy(dst, src)
if err != nil {
    return fmt.Errorf("copy data: %w", err)
}

落とし穴2:ラッピング時の元の情報消失

// ❌ 誤り:%v でエラーチェーンが失われる
return fmt.Errorf("query failed: %v", err)

// ✅ 正しい:%w でエラーチェーンを保持
return fmt.Errorf("query failed: %w", err)

落とし穴3:ゴルーチン内のパニック未リカバリ

// ❌ 誤り:ゴルーチンパニックがプロセス全体を終了させる
go func() {
    result := doSomething()
    ch <- result
}()

// ✅ 正しい:ゴルーチン入口でリカバリ
go func() {
    defer func() {
        if rec := recover(); rec != nil {
            log.Printf("goroutine panic: %v\n%s", rec, debug.Stack())
            ch <- nil
        }
    }()
    result := doSomething()
    ch <- result
}()

落とし穴4:文字列マッチングによるエラー検査

// ❌ 誤り:文字列マッチングは脆弱
if strings.Contains(err.Error(), "timeout") {
    // ...
}

// ✅ 正しい:errors.Is でチェック
if errors.Is(err, context.DeadlineExceeded) {
    // ...
}

落とし穴5:同じエラーの重複ラッピング

// ❌ 誤り:多層の重複ラッピングで冗長なエラーメッセージ
func a() error {
    return fmt.Errorf("a: %w", err)
}
func b() error {
    return fmt.Errorf("b: %w", a())  // "b: a: original error"
}
func c() error {
    return fmt.Errorf("c: %w", b())  // "c: b: a: original error"
}

// ✅ 正しい:境界層でのみラッピングし、内部はそのまま渡す
func a() error {
    return err  // 内部はそのまま渡す
}
func b() error {
    return a()
}
func c() error {
    return fmt.Errorf("process order: %w", b())  // 境界で1回だけラッピング
}

10のよくあるエラートラブルシューティング

エラー症状 考えられる原因 調査方法 解決策
sql: no rows in result set QueryRowの結果なし SQL WHERE条件を確認 errors.Is(err, sql.ErrNoRows) で判定
context deadline exceeded 操作のタイムアウト contextのタイムアウト設定を確認 タイムアウトの延長またはクエリパフォーマンスの最適化
panic: concurrent map writes mapの並行書き込み ゴルーチン間のmap共有を確認 sync.Map またはミューテックスを使用
panic: send on closed channel クローズ済みチャネルへの送信 チャネルのクローズタイミングを確認 sync.Onceまたは方向チャネルで制御
connection refused サービス未起動またはポート誤り サービスステータスとポートを確認 サービス起動を確認、ネットワーク設定をチェック
i/o timeout ネットワークタイムアウト ネットワーク接続性を確認 タイムアウトの延長、ファイアウォールルールの確認
record not found データが存在しない クエリ条件を確認 「存在しない」と「クエリ失敗」を区別
duplicate key value ユニーク制約違反 挿入データを確認 errors.As でPGエラーコードを抽出
panic: nil pointer dereference nilポインタのデリファレンス ポインタの初期化を確認 nilチェックの追加、Recoveryミドルウェアを使用
too many open files ファイルディスクリプタ枯渇 コネクションプールとファイル操作を確認 ulimitの増加、コネクションリークの確認

高度な最適化テクニック

テクニック1:エラーグルーピングと集約

package apperr

import "strings"

type ErrorGroup struct {
    Errors []error
}

func (g *ErrorGroup) Add(err error) {
    if err != nil {
        g.Errors = append(g.Errors, err)
    }
}

func (g *ErrorGroup) Err() error {
    if len(g.Errors) == 0 {
        return nil
    }
    return g
}

func (g *ErrorGroup) Error() string {
    msgs := make([]string, len(g.Errors))
    for i, err := range g.Errors {
        msgs[i] = err.Error()
    }
    return strings.Join(msgs, "; ")
}

func (g *ErrorGroup) Unwrap() []error {
    return g.Errors
}

テクニック2:エラーリトライとバックオフ

package retry

import (
    "context"
    "fmt"
    "math"
    "time"
)

type Config struct {
    MaxAttempts int
    BaseDelay   time.Duration
    MaxDelay    time.Duration
    Retryable   func(error) bool
}

func Do(ctx context.Context, cfg Config, fn func() error) error {
    var lastErr error
    for attempt := 0; attempt < cfg.MaxAttempts; attempt++ {
        if err := fn(); err != nil {
            if !cfg.Retryable(err) {
                return fmt.Errorf("non-retryable error: %w", err)
            }
            lastErr = err

            delay := time.Duration(
                float64(cfg.BaseDelay) * math.Pow(2, float64(attempt)),
            )
            if delay > cfg.MaxDelay {
                delay = cfg.MaxDelay
            }

            select {
            case <-ctx.Done():
                return ctx.Err()
            case <-time.After(delay):
                continue
            }
        }
        return nil
    }
    return fmt.Errorf("max attempts (%d) exceeded: %w", cfg.MaxAttempts, lastErr)
}

テクニック3:エラーコードからHTTPステータスへのマッピング

package handler

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

var codeToStatus = map[string]int{
    "NOT_FOUND":       http.StatusNotFound,
    "VALIDATION_ERROR": http.StatusBadRequest,
    "UNAUTHORIZED":    http.StatusUnauthorized,
    "FORBIDDEN":       http.StatusForbidden,
    "CONFLICT":        http.StatusConflict,
    "RATE_LIMITED":    http.StatusTooManyRequests,
    "DB_ERROR":        http.StatusInternalServerError,
}

func MapErrorToHTTP(err error) (int, string) {
    var domainErr *apperr.DomainError
    if errors.As(err, &domainErr) {
        if status, ok := codeToStatus[domainErr.Code]; ok {
            return status, domainErr.Message
        }
    }

    if errors.Is(err, apperr.ErrNotFound) {
        return http.StatusNotFound, "resource not found"
    }
    if errors.Is(err, apperr.ErrUnauthorized) {
        return http.StatusUnauthorized, "unauthorized"
    }

    return http.StatusInternalServerError, "internal server error"
}

テクニック4:構造化エラーログ

package logger

import (
    "log/slog"
    "apperr"
    "errors"
)

func LogError(err error, context ...slog.Attr) {
    attrs := []slog.Attr{
        slog.String("error.message", err.Error()),
    }

    var domainErr *apperr.DomainError
    if errors.As(err, &domainErr) {
        attrs = append(attrs,
            slog.String("error.domain", domainErr.Domain),
            slog.String("error.code", domainErr.Code),
            slog.Time("error.timestamp", domainErr.Timestamp),
        )
    }

    attrs = append(attrs, context...)
    slog.LogAttrs(nil, slog.LevelError, "error occurred", attrs...)
}

テクニック5:エラーアサーションヘルパー関数

package apperr

import "errors"

func IsNotFound(err error) bool {
    return errors.Is(err, ErrNotFound)
}

func IsConflict(err error) bool {
    return errors.Is(err, ErrConflict)
}

func IsRateLimited(err error) bool {
    return errors.Is(err, ErrRateLimited)
}

func GetDomainError(err error) (*DomainError, bool) {
    var de *DomainError
    return de, errors.As(err, &de)
}

func GetCode(err error) string {
    var de *DomainError
    if errors.As(err, &de) {
        return de.Code
    }
    return "UNKNOWN"
}

比較分析

特徴 fmt.Errorf %w カスタムエラー型 errors.Is/As パニックリカバリ エラーミドルウェアチェーン OpenTelemetry
エラーチェーンの保持
ビジネスセマンティクスの付与
型安全な検査
プロセスクラッシュ防止
統一的な横断的処理
分散トレーシング 部分
メトリクス収集
実装複雑度
プロダクション必須度 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐
適用レイヤー 全体 ビジネス 検査 ランタイム HTTP オブザーバビリティ

おすすめオンラインツール

  • JSONフォーマッター — エラーレスポンスのJSON構造をフォーマットし、APIエラーメッセージを迅速にトラブルシューティング
  • ハッシュ計算ツール — リクエスト署名とデータチェックサムを計算し、エラーログのデータ整合性を確保
  • cURL→コード変換 — cURLコマンドをGoコードに変換し、HTTPエラーを迅速に再現・デバッグ

まとめ

Goのエラーハンドリングは if err != nil を追加するだけではない——5つの問いに答える必要がある:エラーはどこから来たか?何のタイプか?どう伝播するか?どうリカバリするか?どう観測するか? fmt.Errorf("%w", err) は「どこから」を、カスタムエラー型は「何のタイプ」を、errors.Is/As は「どう検査するか」を、リカバリミドルウェアは「どうリカバリするか」を、OpenTelemetryは「どう観測するか」に答える。この6つのパターンをマスターすれば、プロダクション級Goエラーハンドリングの核心的な方法論を身につけたことになる。


参考リンク

ブラウザローカルツールを無料で試す →

#Go错误处理#错误包装#自定义错误#panic恢复#Go 1.24#2026#编程语言