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.Asとerrors.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.Canceledやcontext.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まで記録する設定にするのが一般的ですね。

