Let's Go Further ارسال پاسخ‌های JSON › ارسال پیام‌های خطا
قبلی · فهرست · بعدی
فصل ۳.۶.

ارسال پیام‌های خطا

در این نقطه، API ما برای requestهای موفق پاسخ‌های JSON خوش‌ساخت ارسال می‌کند، اما اگر یک client درخواست بدی بفرستد، یا چیزی در application ما اشتباه پیش برود، هنوز با استفاده از functionهای http.Error() و http.NotFound() یک پیام خطای plain-text برای او ارسال می‌کنیم.

در این فصل این مشکل را با ساخت چند helper اضافی برای مدیریت خطاها و ارسال پاسخ‌های JSON مناسب به clientها برطرف می‌کنیم.

اگر همراه کتاب جلو می‌روید، یک فایل جدید به نام cmd/api/errors.go بسازید:

$ touch cmd/api/errors.go

و بعد چند helper method مثل این‌ها اضافه کنید:

File: cmd/api/errors.go
package main

import (
    "fmt"
    "net/http"
)

// The logError() method is a helper for logging an error message, along
// with the current request method and URL as attributes in the log entry.
func (app *application) logError(r *http.Request, err error) {
    var (
        method = r.Method
        uri    = r.URL.RequestURI()
    )

    app.logger.Error(err.Error(), "method", method, "uri", uri)
}

// The errorResponse() method is a helper for sending JSON-formatted error
// messages to the client with a given status code. Note that we're using the any
// type for the message parameter, rather than just a string type, as this gives us
// more flexibility over the values that we can include in the response.
func (app *application) errorResponse(w http.ResponseWriter, r *http.Request, status int, message any) {
    env := envelope{"error": message}

    // Write the response using the writeJSON() helper. If this happens to return an
    // error then log it, and fall back to sending the client an empty response with a
    // 500 Internal Server Error status code.
    err := app.writeJSON(w, status, env, nil)
    if err != nil {
        app.logError(r, err)
        w.WriteHeader(500)
    }
}

// The serverErrorResponse() method will be used when our application encounters an
// unexpected problem at runtime. It logs the detailed error message, then uses the
// errorResponse() helper to send a 500 Internal Server Error status code and JSON
// response (containing a generic error message) to the client.
func (app *application) serverErrorResponse(w http.ResponseWriter, r *http.Request, err error) {
    app.logError(r, err)

    message := "the server encountered a problem and could not process your request"
    app.errorResponse(w, r, http.StatusInternalServerError, message)
}

// The notFoundResponse() method will be used to send a 404 Not Found status code and
// JSON response to the client.
func (app *application) notFoundResponse(w http.ResponseWriter, r *http.Request) {
    message := "the requested resource could not be found"
    app.errorResponse(w, r, http.StatusNotFound, message)
}

// The methodNotAllowedResponse() method will be used to send a 405 Method Not Allowed
// status code and JSON response to the client.
func (app *application) methodNotAllowedResponse(w http.ResponseWriter, r *http.Request) {
    message := fmt.Sprintf("the %s method is not supported for this resource", r.Method)
    app.errorResponse(w, r, http.StatusMethodNotAllowed, message)
}

حالا که این‌ها آماده شدند، handlerهای API را به‌روزرسانی می‌کنیم تا به جای functionهای http.Error() و http.NotFound() از این helperهای جدید استفاده کنند. به این شکل:

File: cmd/api/healthcheck.go
package main

...

func (app *application) healthcheckHandler(w http.ResponseWriter, r *http.Request) {
    env := envelope{
        "status": "available",
        "system_info": map[string]string{
            "environment": app.config.env,
            "version":     version,
        },
    }

    err := app.writeJSON(w, http.StatusOK, env, nil)
    if err != nil {
        // Use the new serverErrorResponse() helper.
        app.serverErrorResponse(w, r, err)
    }
}
File: cmd/api/movies.go
package main

...

func (app *application) showMovieHandler(w http.ResponseWriter, r *http.Request) {
    id, err := app.readIDParam(r)
    if err != nil {
        // Use the new notFoundResponse() helper.
        app.notFoundResponse(w, r)
        return
    }

    movie := data.Movie{
        ID:        id,
        CreatedAt: time.Now(),
        Title:     "Casablanca",
        Runtime:   102,
        Genres:    []string{"drama", "romance", "war"},
        Version:   1,
    }

    err = app.writeJSON(w, http.StatusOK, envelope{"movie": movie}, nil)
    if err != nil {
        // Use the new serverErrorResponse() helper.
        app.serverErrorResponse(w, r, err)
    }
}

خطاهای routing

هر پیام خطایی که handlerهای API خودمان ارسال کنند، حالا یک پاسخ JSON خوش‌ساخت خواهد بود. عالی است!

اما پیام‌های خطایی که httprouter وقتی route منطبق پیدا نمی‌کند به صورت خودکار می‌فرستد چه؟ به صورت پیش‌فرض، این‌ها همچنان همان پاسخ‌های plain-text و غیر JSON هستند که قبلا در کتاب دیدیم.

خوشبختانه httprouter اجازه می‌دهد error handlerهای سفارشی خودمان را تنظیم کنیم. این handlerهای سفارشی باید interface مربوط به http.Handler را satisfy کنند، و این برای ما خبر خوبی است چون یعنی می‌توانیم helperهای notFoundResponse() و methodNotAllowedResponse() را که همین الان ساختیم به‌راحتی reuse کنیم.

فایل cmd/api/routes.go را باز کنید و instance مربوط به httprouter را به این شکل configure کنید:

File: cmd/api/routes.go
package main

...

func (app *application) routes() http.Handler {
    router := httprouter.New()

    // Convert the notFoundResponse() helper to a http.Handler using the 
    // http.HandlerFunc() adapter, and then set it as the custom error handler for 404
    // Not Found responses.
    router.NotFound = http.HandlerFunc(app.notFoundResponse)

    // Likewise, convert the methodNotAllowedResponse() helper to a http.Handler and set
    // it as the custom error handler for 405 Method Not Allowed responses.
    router.MethodNotAllowed = http.HandlerFunc(app.methodNotAllowedResponse)

    router.HandlerFunc(http.MethodGet, "/v1/healthcheck", app.healthcheckHandler)
    router.HandlerFunc(http.MethodPost, "/v1/movies", app.createMovieHandler)
    router.HandlerFunc(http.MethodGet, "/v1/movies/:id", app.showMovieHandler)

    return router
}

بیایید این تغییرات را امتحان کنیم. application را دوباره راه‌اندازی کنید، بعد چند request برای endpointهایی که وجود ندارند یا از HTTP method پشتیبانی‌نشده استفاده می‌کنند بفرستید. حالا باید چند پاسخ خطای JSON مرتب شبیه این‌ها بگیرید:

$ curl -i localhost:4000/foo
HTTP/1.1 404 Not Found
Content-Type: application/json
Date: Tue, 06 Apr 2021 15:13:42 GMT
Content-Length: 58

{
    "error": "the requested resource could not be found"
}

$ curl -i localhost:4000/v1/movies/abc
HTTP/1.1 404 Not Found
Content-Type: application/json
Date: Tue, 06 Apr 2021 15:14:01 GMT
Content-Length: 58

{
    "error": "the requested resource could not be found"
}

$ curl -i -X PUT localhost:4000/v1/healthcheck
HTTP/1.1 405 Method Not Allowed
Allow: GET, OPTIONS
Content-Type: application/json
Date: Tue, 06 Apr 2021 15:14:21 GMT
Content-Length: 66

{
    "error": "the PUT method is not supported for this resource"
}

در مثال آخر توجه کنید که httprouter هنوز به صورت خودکار header درست Allow را برای ما تنظیم می‌کند، با اینکه حالا برای پاسخ از error handler سفارشی ما استفاده می‌کند.

بازیابی از panic

در پایان، بیایید ببینیم اگر در کد handler ما یک runtime panic رخ بدهد چه اتفاقی می‌افتد.

  1. اجرای عادی کد در handler بلافاصله متوقف می‌شود.
  2. هر function از نوع deferred برای goroutine فعلی، به ترتیب معکوس، یعنی last-in, first-out، اجرا می‌شود.
  3. بعد panic توسط http.Server در Go recover می‌شود و connection زیربنایی HTTP بسته خواهد شد.
  4. یک پیام خطا و stack trace در http.Server.ErrorLog خروجی داده می‌شود.
  5. هیچ HTTP request دیگری تحت تاثیر panic قرار نمی‌گیرد.

این رفتار قابل قبول است، اما برای client بهتر است اگر بتوانیم یک پاسخ 500 Internal Server Error هم بفرستیم تا توضیح بدهیم چیزی اشتباه پیش رفته، نه اینکه فقط connection مربوط به HTTP را بدون هیچ توضیحی ببندیم.

در Let’s Go جزئیات انجام این کار را با ساختن middleware برای recover کردن panic توسط خودمان مرور کردیم، و منطقی است اینجا هم دوباره همان کار را انجام دهیم.

اگر همراه کتاب جلو می‌روید، یک فایل cmd/api/middleware.go بسازید:

$ touch cmd/api/middleware.go

و داخل آن فایل یک middleware جدید به نام recoverPanic() اضافه کنید:

File: cmd/api/middleware.go
package main

import (
    "fmt"
    "net/http"
)

func (app *application) recoverPanic(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Create a deferred function (which will always be run in the event
        // of a panic).
        defer func() {
            // Use the built-in recover() function to check if a panic occurred.
            // If a panic did happen, recover() will return the panic value. If
            // a panic didn't happen, it will return nil.
            pv := recover()
            if pv != nil {
                // If there was a panic, set a "Connection: close" header on the 
                // response. This acts as a trigger to make Go's HTTP server 
                // automatically close the current connection after the response has been 
                // sent.
                w.Header().Set("Connection", "close")
                // The value returned by recover() has the type any, so we use
                // fmt.Errorf() with the %v verb to coerce it into an error and  
                // call our serverErrorResponse() helper. In turn, this will log the 
                // error at the ERROR level and send the client a 500 Internal 
                // Server Error response.
                app.serverErrorResponse(w, r, fmt.Errorf("%v", pv))
            }
        }()

        next.ServeHTTP(w, r)
    })
}

وقتی این کار انجام شد، باید فایل cmd/api/routes.go را به‌روزرسانی کنیم تا middleware مربوط به recoverPanic() دور router ما wrap شود. این کار مطمئن می‌کند که middleware برای همه endpointهای API اجرا می‌شود.

File: cmd/api/routes.go
package main

...

func (app *application) routes() http.Handler {
    router := httprouter.New()

    router.NotFound = http.HandlerFunc(app.notFoundResponse)
    router.MethodNotAllowed = http.HandlerFunc(app.methodNotAllowedResponse)

    router.HandlerFunc(http.MethodGet, "/v1/healthcheck", app.healthcheckHandler)
    router.HandlerFunc(http.MethodPost, "/v1/movies", app.createMovieHandler)
    router.HandlerFunc(http.MethodGet, "/v1/movies/:id", app.showMovieHandler)

    // Wrap the router with the panic recovery middleware.
    return app.recoverPanic(router)
}

حالا که این آماده است، اگر در یکی از handlerهای API ما panic رخ بدهد، middleware مربوط به recoverPanic() آن را recover می‌کند و helper معمولی app.serverErrorResponse() را صدا می‌زند. آن helper هم خطا را با structured logger ما log می‌کند و یک پاسخ مرتب 500 Internal Server Error همراه با JSON body برای client می‌فرستد.


اطلاعات تکمیلی

پاسخ‌های خطای تولیدشده توسط سیستم

حالا که درباره خطاها صحبت می‌کنیم، می‌خواهم اشاره کنم که در بعضی سناریوها، http.Server در Go ممکن است همچنان به صورت خودکار پاسخ‌های HTTP از نوع plain-text تولید و ارسال کند. این سناریوها شامل این موارد هستند:

برای مثال، اگر تلاش کنیم requestای با مقدار نامعتبر برای header مربوط به Host بفرستیم، پاسخی شبیه این می‌گیریم:

$ curl -i -H "Host: こんにちは"  http://localhost:4000/v1/healthcheck
HTTP/1.1 400 Bad Request: malformed Host header
Content-Type: text/plain; charset=utf-8
Connection: close

400 Bad Request: malformed Host header

متاسفانه این پاسخ‌ها در standard library خود Go hard-code شده‌اند و کاری نمی‌توانیم انجام دهیم تا آن‌ها را سفارشی کنیم و به جای plain-text از JSON استفاده کنند.

اما با اینکه باید از این موضوع آگاه باشید، لزوما چیزی نیست که بابتش نگران شوید. در محیط production، بعید است clientهای خوش‌رفتار و غیر مخرب این پاسخ‌ها را trigger کنند، و اگر گاهی برای clientهای بد به جای JSON یک پاسخ plain-text ارسال شد، نباید بیش از حد نگران باشیم.

بازیابی panic در goroutineهای دیگر

خیلی مهم است بدانید middleware ما فقط panicهایی را recover می‌کند که در همان goroutineای رخ داده‌اند که middleware مربوط به recoverPanic() را اجرا کرده است.

برای مثال، اگر handlerای دارید که یک goroutine دیگر راه‌اندازی می‌کند، مثلا برای انجام پردازش در background، هر panicای که در goroutine پس‌زمینه رخ بدهد recover نخواهد شد؛ نه توسط middleware مربوط به recoverPanic()… و نه توسط panic recovery داخلی http.Server. این panicها باعث exit شدن application و پایین آمدن server می‌شوند.

پس اگر از داخل handlerها goroutineهای اضافی راه‌اندازی می‌کنید و هر احتمالی برای panic وجود دارد، باید مطمئن شوید که panicها را از داخل همان goroutineها هم recover می‌کنید.

بعدتر در کتاب با جزئیات بیشتری به این موضوع نگاه می‌کنیم و وقتی از یک background goroutine برای ارسال ایمیل خوشامدگویی به کاربران API استفاده کنیم، نشان می‌دهیم چطور باید با آن برخورد کرد.