Menangani kesalahan HTTP dengan lebih sedikit merepotkan + RFC7807
Gambaran Umum
Ketika membuat http API dalam bahasa Go, hal yang paling merepotkan adalah penanganan error. Secara umum, ada kode seperti ini.
1func(w http.ResponseWriter, r *http.Request) {
2 err := doSomething()
3 if err != nil {
4 http.Error(w, err.Error(), http.StatusInternalServerError)
5 log.Printf("error occurred: %v", err)
6 return
7 }
8 // ...
9}
Jika jumlah API tidak banyak, menulis seperti ini mungkin tidak akan terlalu merepotkan. Namun, seiring bertambahnya jumlah API dan semakin kompleksnya logika internal, tiga hal ini akan menjadi mengganggu.
- Pengembalian kode error yang tepat
- Banyaknya jumlah penulisan log hasil
- Pengiriman pesan error yang jelas
Pembahasan
Pengembalian kode error yang tepat
Tentu saja, poin 1, pengembalian kode error yang tepat, adalah keluhan pribadi saya. Developer berpengalaman akan mencari kode yang tepat dan memasukkannya dengan baik setiap saat, namun developer yang belum berpengalaman, termasuk saya, mungkin akan kesulitan untuk secara konsisten menggunakan kode error yang sesuai seiring dengan semakin kompleksnya logika dan meningkatnya jumlah iterasi. Ada berbagai cara untuk mengatasi hal ini, dan yang paling umum adalah merancang alur logika API terlebih dahulu kemudian menulis kode untuk mengembalikan error yang sesuai. Lakukan itu
Namun, ini tampaknya bukan cara terbaik bagi developer manusia yang dibantu oleh IDE (atau Language Server). Selain itu, karena REST API itu sendiri memanfaatkan semaksimal mungkin makna yang terkandung dalam kode error, cara lain dapat diusulkan. Kita dapat membuat implementasi antarmuka error
baru bernama HttpError
untuk menyimpan StatusCode
dan Message
. Dan menyediakan fungsi helper berikut.
1err := httperror.BadRequest("wrong format")
Fungsi helper BadRequest
akan mengembalikan HttpError
dengan StatusCode
400 dan Message
yang diatur ke nilai yang diterima sebagai argumen. Selain itu, tentu saja, fungsi helper seperti NotImplement
, ServiceUnavailable
, Unauthorized
, PaymentRequired
, dll. dapat dicari dan ditambahkan menggunakan fitur auto-complete. Ini akan lebih cepat daripada memeriksa dokumen desain yang sudah disiapkan setiap saat, dan akan lebih stabil daripada memasukkan kode error berupa angka setiap saat. Bukankah semuanya ada di konstanta http.StatusCode
? Shh
Banyaknya jumlah penulisan log hasil
Ketika terjadi error, log tentu saja akan dicatat. Saat API dipanggil, mencatat log di setiap titik akhir yang diperkirakan, mulai dari awal, untuk mengetahui apakah permintaan berhasil atau gagal, akan meningkatkan jumlah kode yang harus ditulis. Ini dapat dikelola secara terpusat dengan membungkus handler itu sendiri.
Berikut adalah contoh membungkus router chi
.
1package chiwrap
2
3import (
4 "errors"
5 "net/http"
6
7 "github.com/go-chi/chi/v5"
8
9 "github.com/gosuda/httpwrap/httperror"
10)
11
12type Router struct {
13 router chi.Router
14 errCallback func(err error)
15}
16
17func NewRouter(errCallback func(err error)) *Router {
18 if errCallback == nil {
19 errCallback = func(err error) {}
20 }
21 return &Router{
22 router: chi.NewRouter(),
23 errCallback: errCallback,
24 }
25}
26
27type HandlerFunc func(writer http.ResponseWriter, request *http.Request) error
28
29func (r *Router) Get(pattern string, handler HandlerFunc) {
30 r.router.Get(pattern, func(writer http.ResponseWriter, request *http.Request) {
31 if err := handler(writer, request); err != nil {
32 he := &httperror.HttpError{}
33 switch errors.As(err, &he) {
34 case true:
35 http.Error(writer, he.Message, he.Code)
36 case false:
37 http.Error(writer, err.Error(), http.StatusInternalServerError)
38 }
39 r.errCallback(err)
40 }
41 })
42}
Struktur Router memiliki chi.Router
di dalamnya, sehingga dikonfigurasi untuk menggunakan fungsionalitas chi.Router
apa adanya. Jika Anda melihat metode Get
, ia memeriksa apakah struktur HttpError
yang dikembalikan oleh fungsi helper yang baru saja diusulkan di atas telah dikembalikan, kemudian mengembalikannya dengan tepat, dan jika itu adalah error
biasa, ia meneruskannya secara seragam ke fungsi callback error. Callback ini diterima melalui konstruktor.
Berikut adalah kode yang ditulis menggunakan paket ini.
1package main
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "io"
8 "log"
9 "net/http"
10 "os"
11 "os/signal"
12 "syscall"
13
14 "github.com/gosuda/httpwrap/httperror"
15 "github.com/gosuda/httpwrap/wrapper/chiwrap"
16)
17
18func main() {
19 ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
20 defer cancel()
21
22 r := chiwrap.NewRouter(func(err error) {
23 log.Printf("Router log test: Error occured: %v", err)
24 })
25 r.Get("/echo", func(writer http.ResponseWriter, request *http.Request) error {
26 name := request.URL.Query().Get("name")
27 if name == "" {
28 return httperror.BadRequest("name is required")
29 }
30
31 writer.Write([]byte("Hello " + name))
32 return nil
33 })
34
35 svr := http.Server{
36 Addr: ":8080",
37 Handler: r,
38 }
39 go func() {
40 if err := svr.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
41 log.Fatalf("Failed to start server: %v", err)
42 }
43 }()
44
45 <-ctx.Done()
46 svr.Shutdown(context.Background())
47}
Bagaimana menurut Anda? Cukup dengan membuat HttpError
sebagai fungsi helper dan mengembalikannya, kita dapat mengembalikan respons dengan kode error dan pesan yang sesuai dari scope yang lebih tinggi, dan menangani log yang sesuai untuk setiap layanan implementasi dengan mendaftarkan callback. Jika diperlukan, kita dapat memperluasnya untuk memungkinkan logging yang lebih rinci menggunakan RequestID
, dll.
Pengiriman pesan error yang jelas
Dokumen untuk ini adalah RFC7807. RFC7807 umumnya mendefinisikan dan menggunakan elemen-elemen berikut.
type
: URI yang mengidentifikasi jenis error. Biasanya merupakan dokumen yang menjelaskan error tersebut.title
: Penjelasan singkat tentang jenis error.status
: Sama dengan HTTP Status Code.detail
: Penjelasan rinci tentang error tersebut yang dapat dibaca oleh manusia.instance
: URI tempat error terjadi. Misalnya, jika error terjadi padaGET /user/info
, maka/user/info
akan menjadi nilainya.extensions
: Elemen tambahan dalam bentuk JSON Object untuk menjelaskan error.- Misalnya, untuk
BadRequest
, input pengguna dapat disertakan. - Atau untuk
TooManyRequest
, waktu permintaan terakhir dapat disertakan.
- Misalnya, untuk
Untuk memudahkan penggunaannya, kita dapat membuat file baru di paket httperror
, di lokasi yang sama dengan HttpError
, membuat struktur RFC7807Error
, dan memungkinkannya dibuat dengan pola method chaining.
1func NewRFC7807Error(status int, title, detail string) *RFC7807Error {
2 return &RFC7807Error{
3 Type: "about:blank", // Default type as per RFC7807
4 Title: title,
5 Status: status,
6 Detail: detail,
7 }
8}
9
10func BadRequestProblem(detail string, title ...string) *RFC7807Error {
11 t := "Bad Request"
12 if len(title) > 0 && title[0] != "" {
13 t = title[0]
14 }
15 return NewRFC7807Error(http.StatusBadRequest, t, detail)
16}
17
18func (p *RFC7807Error) WithType(typeURI string) *RFC7807Error { ... }
19func (p *RFC7807Error) WithInstance(instance string) *RFC7807Error { ... }
20func (p *RFC7807Error) WithExtension(key string, value interface{}) *RFC7807Error { ... }
"about:blank"
untuk Type
adalah nilai default. Ini berarti halaman yang tidak ada. Di bawah ini adalah contoh pembuatan error untuk permintaan yang salah.
1problem := httperror.BadRequestProblem("invalid user id format", "Bad User Input")
2
3problem = problem.WithType("https://example.com/errors/validation")
4 .WithInstance("/api/users/abc")
5 .WithExtension("invalid_field", "user_id")
6 .WithExtension("expected_format", "numeric")
Dengan method chaining yang sederhana, pesan error terstruktur untuk pengguna dapat dibuat. Selain itu, untuk menggunakan router terpusat yang telah ditulis sebelumnya, metode berikut dapat didukung.
1func (p *RFC7807Error) ToHttpError() *HttpError {
2 jsonBytes, err := json.Marshal(p)
3 if err != nil {
4 // If marshaling fails, fall back to just using the detail
5 return New(p.Status, p.Detail)
6 }
7 return New(p.Status, string(jsonBytes))
8}
Menggunakan ini apa adanya untuk memodifikasi contoh di atas akan menjadi seperti ini.
1package main
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "io"
8 "log"
9 "net/http"
10 "os"
11 "os/signal"
12 "syscall"
13
14 "github.com/gosuda/httpwrap/httperror"
15 "github.com/gosuda/httpwrap/wrapper/chiwrap"
16)
17
18func main() {
19 ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
20 defer cancel()
21
22 r := chiwrap.NewRouter(func(err error) {
23 log.Printf("Router log test: Error occured: %v", err)
24 })
25 r.Get("/echo", func(writer http.ResponseWriter, request *http.Request) error {
26 name := request.URL.Query().Get("name")
27 if name == "" {
28 return httperror.BadRequestProblem("name is required", "Bad User Input").
29 WithType("https://example.com/errors/validation").
30 WithInstance("/api/echo").
31 WithExtension("invalid_field", "name").
32 WithExtension("expected_format", "string").
33 WithExtension("actual_value", name).
34 ToHttpError()
35 }
36
37 writer.Write([]byte("Hello " + name))
38 return nil
39 })
40
41 svr := http.Server{
42 Addr: ":8080",
43 Handler: r,
44 }
45 go func() {
46 if err := svr.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
47 log.Fatalf("Failed to start server: %v", err)
48 }
49 }()
50
51 <-ctx.Done()
52 svr.Shutdown(context.Background())
53}
Kesimpulan
Dengan demikian, menggunakan router terpusat untuk menangani error dapat mengurangi beban untuk memeriksa kode error dan menulis pesan error yang sesuai setiap saat. Selain itu, dengan memanfaatkan RFC7807 untuk menyediakan pesan error yang terstruktur, ini dapat membantu klien memahami dan menangani error. Melalui metode ini, penanganan error pada HTTP API yang ditulis dengan bahasa Go dapat dibuat lebih sederhana dan konsisten.
Kode untuk artikel ini dapat ditemukan di repositori gosuda/httpwrap.