Go の Gin でのエラーハンドリング

Gin でのエラーハンドリングは、一貫したレスポンス形式と適切なステータスコードを返すことが重要だ。エラー処理を統一することで、API の品質が向上する。

基本的なエラーレスポンス

r.GET("/users/:id", func(c *gin.Context) {
    id := c.Param("id")
    
    user, err := userService.FindByID(id)
    if err != nil {
        if errors.Is(err, ErrUserNotFound) {
            c.JSON(404, gin.H{"error": "User not found"})
            return
        }
        c.JSON(500, gin.H{"error": "Internal server error"})
        return
    }
    
    c.JSON(200, user)
})

エラーの種類によってステータスコードを使い分ける。

統一されたエラーレスポンス構造体

API 全体で一貫したエラー形式を使うと、クライアント側の実装が楽になる。

type APIError struct {
    Code    string `json:"code"`
    Message string `json:"message"`
    Details any    `json:"details,omitempty"`
}

func NewAPIError(code, message string) *APIError {
    return &APIError{Code: code, Message: message}
}

// 使用例
c.JSON(400, NewAPIError("INVALID_INPUT", "入力値が不正です"))
c.JSON(404, NewAPIError("NOT_FOUND", "リソースが見つかりません"))
c.JSON(500, NewAPIError("INTERNAL_ERROR", "サーバーエラーが発生しました"))

アプリケーションエラー型の定義

ドメイン固有のエラー型を定義すると、エラー処理がしやすくなる。

type AppError struct {
    StatusCode int
    Code       string
    Message    string
    Err        error
}

func (e *AppError) Error() string {
    return e.Message
}

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

// よく使うエラーを定義
func ErrNotFound(message string) *AppError {
    return &AppError{StatusCode: 404, Code: "NOT_FOUND", Message: message}
}

func ErrBadRequest(message string) *AppError {
    return &AppError{StatusCode: 400, Code: "BAD_REQUEST", Message: message}
}

func ErrInternal(err error) *AppError {
    return &AppError{StatusCode: 500, Code: "INTERNAL_ERROR", Message: "Internal server error", Err: err}
}

エラーハンドリングミドルウェア

エラー処理を一箇所にまとめるミドルウェアを作成できる。

func ErrorHandler() gin.HandlerFunc {
    return func(c *gin.Context) {
        c.Next()
        
        // エラーがあれば処理
        if len(c.Errors) > 0 {
            err := c.Errors.Last().Err
            
            var appErr *AppError
            if errors.As(err, &appErr) {
                c.JSON(appErr.StatusCode, gin.H{
                    "code":    appErr.Code,
                    "message": appErr.Message,
                })
                return
            }
            
            // 未知のエラー
            c.JSON(500, gin.H{
                "code":    "INTERNAL_ERROR",
                "message": "An unexpected error occurred",
            })
        }
    }
}

ハンドラ側では c.Error() でエラーを登録する。

r.Use(ErrorHandler())

r.GET("/users/:id", func(c *gin.Context) {
    user, err := userService.FindByID(c.Param("id"))
    if err != nil {
        c.Error(err)
        return
    }
    c.JSON(200, user)
})

Recovery ミドルウェア

panic からの回復はサーバーの安定性に欠かせない。

func CustomRecovery() gin.HandlerFunc {
    return func(c *gin.Context) {
        defer func() {
            if r := recover(); r != nil {
                // スタックトレースをログに記録
                log.Printf("Panic recovered: %v\n%s", r, debug.Stack())
                
                c.JSON(500, gin.H{
                    "code":    "INTERNAL_ERROR",
                    "message": "Internal server error",
                })
                c.Abort()
            }
        }()
        c.Next()
    }
}

r := gin.New()
r.Use(CustomRecovery())

gin.Recovery() も使えるが、カスタムのレスポンス形式にしたい場合は自作する。

バリデーションエラーの処理

バリデーションエラーは詳細な情報をクライアントに返すと親切だ。

func handleValidationError(err error) gin.H {
    var ve validator.ValidationErrors
    if errors.As(err, &ve) {
        details := make([]gin.H, 0, len(ve))
        for _, e := range ve {
            details = append(details, gin.H{
                "field":   e.Field(),
                "tag":     e.Tag(),
                "message": formatFieldError(e),
            })
        }
        return gin.H{
            "code":    "VALIDATION_ERROR",
            "message": "入力値に問題があります",
            "details": details,
        }
    }
    return gin.H{"code": "BAD_REQUEST", "message": err.Error()}
}

r.POST("/users", func(c *gin.Context) {
    var req CreateUserRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(400, handleValidationError(err))
        return
    }
    // ...
})

HTTP ステータスコードの使い分け

4xx(クライアントエラー)

400 Bad Request、401 Unauthorized、403 Forbidden、404 Not Found、422 Unprocessable Entity など。クライアント側の問題を示す。

5xx(サーバーエラー)

500 Internal Server Error、502 Bad Gateway、503 Service Unavailable など。サーバー側の問題を示す。

適切なステータスコードを使うことで、クライアントがエラーの原因を判断しやすくなる。

ログ出力との連携

本番環境では、エラーの詳細をログに記録しつつ、クライアントには最小限の情報だけ返すのがベストプラクティスだ。

r.GET("/users/:id", func(c *gin.Context) {
    user, err := userService.FindByID(c.Param("id"))
    if err != nil {
        // ログには詳細を記録
        log.Printf("Error fetching user: %v", err)
        
        // クライアントには最小限の情報
        if errors.Is(err, ErrUserNotFound) {
            c.JSON(404, gin.H{"error": "User not found"})
        } else {
            c.JSON(500, gin.H{"error": "Internal server error"})
        }
        return
    }
    c.JSON(200, user)
})

内部エラーの詳細を外部に漏らすと、セキュリティリスクになる。エラーハンドリングは単なる例外処理ではなく、API 設計の一部として考えるべきだ。