Tarkibga o'tish

Error handling

Gin handleri hech narsa qaytarmaydi: func(c *gin.Context). Shu sababli xato yuz berganda javobni ko‘pincha o‘sha joyning o‘zida yozish vasvasasi paydo bo‘ladi:

book, err := svc.Get(ctx, id)
if err != nil {
    c.JSON(500, gin.H{"error": err.Error()}) // ichki xato matni klientga ketdi!
    return
}

Bunday kod o‘nlab handlerda takrorlanadi. Har birida format bir oz farq qiladi, ba’zilarida esa ichki xato matni to‘g‘ridan-to‘g‘ri klientga yuboriladi.

Request timing va error handler middleware darsida net/http uchun bu muammo HandlerE turi bilan hal qilingan edi: handler xatoni qaytaradi, uni esa bitta joy qayta ishlaydi. Gin’da buning uchun tayyor mexanizm bor: c.Error() va c.Errors.

c.Error va c.Errors

c.Error(err) xatoni joriy Contextning xatolar ro‘yxatiga qo‘shadi. U javob yozmaydi va zanjirni to‘xtatmaydi:

func getBook(c *gin.Context) {
    book, err := svc.Get(c.Request.Context(), id)
    if err != nil {
        c.Error(err) // xatoni ro‘yxatga qo‘shish
        return       // handlerdan chiqish
    }
    c.JSON(http.StatusOK, book)
}

Xatolar ro‘yxati c.Errors maydonida saqlanadi. Zanjirdagi keyingi middleware’lar uni c.Next()dan keyin o‘qiy oladi. Aynan shu imkoniyat xato middleware’ining asosini tashkil qiladi.

Xato turi: AppError

Klientga ko‘rsatiladigan xatolar uchun alohida tur yaratamiz. Bu Xatoliklarni standart formatda qaytarish darsidagi yondashuvning Gin’dagi ko‘rinishi:

type AppError struct {
    Status  int
    Code    string
    Message string
    Fields  map[string]string
    Err     error // ichki sabab, klientga ko‘rsatilmaydi
}

func (e *AppError) Error() string {
    if e.Err != nil {
        return e.Code + ": " + e.Err.Error()
    }
    return e.Code
}

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

func NotFound(what string) *AppError {
    return &AppError{Status: http.StatusNotFound, Code: "not_found", Message: what + " topilmadi"}
}

func Forbidden() *AppError {
    return &AppError{Status: http.StatusForbidden, Code: "forbidden", Message: "bu amal uchun ruxsat yo‘q"}
}

Xato middleware’i

func ErrorHandler(logger *slog.Logger) gin.HandlerFunc {
    return func(c *gin.Context) {
        c.Next() // avval handlerlar ishlaydi

        if len(c.Errors) == 0 || c.Writer.Written() {
            return // xato yo‘q yoki javob allaqachon yozilgan
        }

        err := c.Errors.Last().Err

        var appErr *AppError
        if !errors.As(err, &appErr) {
            appErr = &AppError{
                Status:  http.StatusInternalServerError,
                Code:    "internal",
                Message: "Ichki server xatosi",
                Err:     err,
            }
        }

        if appErr.Status >= 500 {
            logger.Error("server xatosi", "err", err, "path", c.Request.URL.Path)
        }

        body := gin.H{"code": appErr.Code, "message": appErr.Message}
        if len(appErr.Fields) > 0 {
            body["fields"] = appErr.Fields
        }
        c.JSON(appErr.Status, gin.H{"error": body})
    }
}

Middleware qanday ishlaydi:

  1. c.Next() barcha keyingi handlerlarni bajaradi.
  2. Keyin c.Errors tekshiriladi. Xato bo‘lmasa yoki handler javobni allaqachon yozgan bo‘lsa, hech narsa qilinmaydi.
  3. Oxirgi xato olinadi va *AppError bo‘lsa, uning status kodi va xabari ishlatiladi.
  4. Boshqa har qanday xato 500 ga aylantiriladi, to‘liq matni esa faqat logga yoziladi.

Bu middleware zanjirda handlerlardan oldin ro‘yxatdan o‘tkazilishi kerak. Shunda uning c.Next()dan keyingi qismi handlerlar ishini tugatgach bajariladi.

c.Writer.Written() tekshiruvi muhim. Handler xatoni qo‘shib, javobni ham yozgan bo‘lishi mumkin. Bunday holatda ikkinchi marta javob yozishga urinilmaydi.

Binding xatolarini ham shu yo‘ldan o‘tkazish

Request binding darsidagi validatsiya xatolarini ham AppErrorga aylantirib, c.Error orqali qaytarish mumkin:

func bindErr(err error) *AppError {
    var verrs validator.ValidationErrors
    if errors.As(err, &verrs) {
        fields := make(map[string]string, len(verrs))
        for _, fe := range verrs {
            fields[fe.Field()] = fe.Tag()
        }
        return &AppError{Status: http.StatusUnprocessableEntity, Code: "validation_failed",
            Message: "Ma’lumotlar noto‘g‘ri", Fields: fields}
    }
    return &AppError{Status: http.StatusBadRequest, Code: "bad_request", Message: "So‘rov formati noto‘g‘ri"}
}

func createBook(c *gin.Context) {
    var req CreateBookRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.Error(bindErr(err))
        return
    }
    // ...
}

Endi barcha xatolar — binding, domen va ichki xatolar — bitta middleware orqali bir xil formatda qaytadi.

Domen xatolarini AppErrorga moslash

Service qatlami AppError haqida bilmasligi kerak. U ErrNotFound, ErrForbidden kabi domen xatolarini qaytaradi. Ularni HTTP xatolariga moslashtirish handlerda yoki alohida funksiyada bajariladi:

func mapDomain(err error) error {
    switch {
    case errors.Is(err, book.ErrNotFound):
        return NotFound("kitob")
    case errors.Is(err, book.ErrForbidden):
        return Forbidden()
    default:
        return err // noma’lum — middleware 500 qiladi
    }
}

book, err := svc.Get(ctx, id)
if err != nil {
    c.Error(mapDomain(err))
    return
}

panic uchun: CustomRecovery

gin.Recovery() panicni ushlaydi va 500 qaytaradi, lekin tanasiz. JSON javob kerak bo‘lsa, gin.CustomRecovery ishlatiladi:

r.Use(gin.CustomRecovery(func(c *gin.Context, rec any) {
    logger.Error("panic", "err", rec, "path", c.Request.URL.Path)
    c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
        "error": gin.H{"code": "internal", "message": "Ichki server xatosi"},
    })
}))

CustomRecovery stek izini standart Gin loggeriga o‘zi yozadi. Funksiya ichida esa klientga javob yoziladi va xato o‘z loggerimizga yuboriladi.

404 va 405 javoblari

Gin standart holatda mavjud bo‘lmagan marshrut uchun 404 page not found matnini qaytaradi. JSON javob uchun NoRoute va NoMethod ishlatiladi:

r.HandleMethodNotAllowed = true

r.NoRoute(func(c *gin.Context) {
    c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"code": "not_found", "message": "endpoint topilmadi"}})
})

r.NoMethod(func(c *gin.Context) {
    c.JSON(http.StatusMethodNotAllowed, gin.H{"error": gin.H{"code": "method_not_allowed", "message": "metod ruxsat etilmagan"}})
})

NoMethod faqat HandleMethodNotAllowed = true bo‘lganda ishlaydi. Gin arxitekturasi darsida aytilganidek, bu sozlama standart holatda o‘chiq.

To‘liq misol

main.go
package main

import (
    "errors"
    "fmt"
    "log/slog"
    "net/http"
    "os"
    "strconv"

    "github.com/gin-gonic/gin"
    "github.com/go-playground/validator/v10"
)

// ---------- Domen ----------

var (
    ErrNotFound  = errors.New("kitob topilmadi")
    ErrForbidden = errors.New("ruxsat yo‘q")
)

func findBook(id int) (string, error) {
    switch id {
    case 1:
        return "O‘tkan kunlar", nil
    case 2:
        return "", ErrForbidden
    case 3:
        return "", fmt.Errorf("kitobni o‘qish: %w", errors.New("pq: connection refused"))
    default:
        return "", ErrNotFound
    }
}

// ---------- AppError ----------

type AppError struct {
    Status  int
    Code    string
    Message string
    Fields  map[string]string
    Err     error
}

func (e *AppError) Error() string {
    if e.Err != nil {
        return e.Code + ": " + e.Err.Error()
    }
    return e.Code
}

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

func mapDomain(err error) error {
    switch {
    case errors.Is(err, ErrNotFound):
        return &AppError{Status: http.StatusNotFound, Code: "not_found", Message: "kitob topilmadi"}
    case errors.Is(err, ErrForbidden):
        return &AppError{Status: http.StatusForbidden, Code: "forbidden", Message: "bu amal uchun ruxsat yo‘q"}
    default:
        return err
    }
}

func bindErr(err error) *AppError {
    var verrs validator.ValidationErrors
    if errors.As(err, &verrs) {
        fields := make(map[string]string, len(verrs))
        for _, fe := range verrs {
            fields[fe.Field()] = fe.Tag()
        }
        return &AppError{Status: http.StatusUnprocessableEntity, Code: "validation_failed",
            Message: "Ma’lumotlar noto‘g‘ri", Fields: fields}
    }
    return &AppError{Status: http.StatusBadRequest, Code: "bad_request", Message: "So‘rov formati noto‘g‘ri"}
}

// ---------- Middleware ----------

func ErrorHandler(logger *slog.Logger) gin.HandlerFunc {
    return func(c *gin.Context) {
        c.Next()

        if len(c.Errors) == 0 || c.Writer.Written() {
            return
        }

        err := c.Errors.Last().Err
        var appErr *AppError
        if !errors.As(err, &appErr) {
            appErr = &AppError{Status: http.StatusInternalServerError, Code: "internal",
                Message: "Ichki server xatosi", Err: err}
        }

        if appErr.Status >= 500 {
            logger.Error("server xatosi", "err", err, "path", c.Request.URL.Path)
        }

        body := gin.H{"code": appErr.Code, "message": appErr.Message}
        if len(appErr.Fields) > 0 {
            body["fields"] = appErr.Fields
        }
        c.JSON(appErr.Status, gin.H{"error": body})
    }
}

// ---------- Handlerlar ----------

func getBook(c *gin.Context) {
    id, err := strconv.Atoi(c.Param("id"))
    if err != nil {
        c.Error(&AppError{Status: http.StatusBadRequest, Code: "bad_request", Message: "id son bo‘lishi kerak"})
        return
    }
    title, err := findBook(id)
    if err != nil {
        c.Error(mapDomain(err))
        return
    }
    c.JSON(http.StatusOK, gin.H{"data": gin.H{"id": id, "title": title}})
}

func createBook(c *gin.Context) {
    var req struct {
        Title string `json:"title" binding:"required"`
        Year  int    `json:"year" binding:"gte=1000"`
    }
    if err := c.ShouldBindJSON(&req); err != nil {
        c.Error(bindErr(err))
        return
    }
    c.JSON(http.StatusCreated, gin.H{"data": req})
}

func crash(c *gin.Context) {
    var m map[string]int
    m["x"]++ // panic
}

func main() {
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

    gin.SetMode(gin.ReleaseMode)
    r := gin.New()
    r.HandleMethodNotAllowed = true

    r.Use(gin.CustomRecovery(func(c *gin.Context, rec any) {
        logger.Error("panic", "err", rec, "path", c.Request.URL.Path)
        c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
            "error": gin.H{"code": "internal", "message": "Ichki server xatosi"},
        })
    }))
    r.Use(ErrorHandler(logger))

    r.GET("/books/:id", getBook)
    r.POST("/books", createBook)
    r.GET("/crash", crash)

    r.NoRoute(func(c *gin.Context) {
        c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"code": "not_found", "message": "endpoint topilmadi"}})
    })
    r.NoMethod(func(c *gin.Context) {
        c.JSON(http.StatusMethodNotAllowed, gin.H{"error": gin.H{"code": "method_not_allowed", "message": "metod ruxsat etilmagan"}})
    })

    r.Run(":8080")
}

Sinab ko‘ramiz:

curl localhost:8080/books/1
curl localhost:8080/books/abc
curl localhost:8080/books/2
curl localhost:8080/books/3
curl localhost:8080/books/9
curl -X POST localhost:8080/books -d '{"year":5}'
curl localhost:8080/crash
curl localhost:8080/authors
curl -X DELETE localhost:8080/books/1
{"data":{"id":1,"title":"O‘tkan kunlar"}}
{"error":{"code":"bad_request","message":"id son bo‘lishi kerak"}}
{"error":{"code":"forbidden","message":"bu amal uchun ruxsat yo‘q"}}
{"error":{"code":"internal","message":"Ichki server xatosi"}}
{"error":{"code":"not_found","message":"kitob topilmadi"}}
{"error":{"code":"validation_failed","fields":{"Title":"required","Year":"gte"},"message":"Ma’lumotlar noto‘g‘ri"}}
{"error":{"code":"internal","message":"Ichki server xatosi"}}
{"error":{"code":"not_found","message":"endpoint topilmadi"}}
{"error":{"code":"method_not_allowed","message":"metod ruxsat etilmagan"}}

Server logida faqat 500 xatolari bor. Ular orasida CustomRecovery yozgan stek izi ham chiqadi, bu yerda u tushirib qoldirilgan:

{"level":"ERROR","msg":"server xatosi","err":"kitobni o‘qish: pq: connection refused","path":"/books/3"}
{"level":"ERROR","msg":"panic","err":"assignment to entry in nil map","path":"/crash"}
  • Barcha javoblar bir xil {"error": {...}} formatida.
  • Database xatosi klientga faqat umumiy xabar sifatida yetib bordi. To‘liq matni esa logda saqlandi.
  • panic ushlandi va server ishlashda davom etdi.
  • Validatsiya xatosidagi maydon nomlari Go nomlari ko‘rinishida chiqdi. Ularni JSON nomlariga aylantirish uchun Request binding darsidagi RegisterTagNameFunc qo‘shiladi.

Xulosa

  • Handler xatoni c.Error(err) bilan qo‘shadi va return qiladi. Javobni esa xato middleware’i yozadi.
  • Xato middleware’i c.Next()dan keyin c.Errorsni tekshiradi va uni zanjir boshida ro‘yxatdan o‘tkazish kerak.
  • *AppError klientga ko‘rsatiladi, qolgan xatolar 500 sifatida yashiriladi va logga yoziladi.
  • Domen xatolari HTTP xatolariga handler qatlamida moslashtiriladi.
  • gin.CustomRecovery panic uchun JSON javob qaytaradi.
  • NoRoute va NoMethod 404 hamda 405 javoblarini yagona formatga keltiradi. NoMethod uchun HandleMethodNotAllowed = true kerak.

Keyingi darsda Gin ilovasini production uchun sozlash va tezligini o‘lchashni ko‘ramiz.