Cara Menangani Error HTTP dengan Lebih Mudah + RFC7807

Ikhtisar
Saat membuat HTTP API dengan bahasa Go, bagian yang paling merepotkan adalah penanganan error. Contoh kode yang umum digunakan adalah sebagai berikut.
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 hanya sedikit, penulisan seperti ini tidak akan menimbulkan banyak kendala. Namun, seiring bertambahnya jumlah API dan semakin kompleksnya logika internal, tiga hal berikut mulai terasa mengganggu.
- Pengembalian kode error yang tepat
- Jumlah penulisan log hasil yang banyak
- Pengiriman pesan error yang jelas
Pembahasan
Pengembalian Kode Error yang Tepat
Tentu saja, poin 1, yaitu pengembalian kode error yang tepat, merupakan keluhan pribadi saya. Pengembang yang berpengalaman akan selalu mencari dan menyisipkan kode yang sesuai setiap kali, tetapi saya sendiri dan pengembang yang masih kurang berpengalaman dapat mengalami kesulitan untuk secara konsisten menulis kode error yang sesuai ketika logika menjadi kompleks dan jumlah pemanggilannya semakin banyak. Terdapat berbagai cara untuk mengatasi hal ini, dan yang paling umum adalah merancang alur logika API terlebih dahulu, lalu menulis kode yang mengembalikan error yang sesuai. Silakan lakukan seperti itu
Namun, pendekatan ini tidak tampak sebagai cara yang optimal bagi pengembang manusia yang dibantu oleh IDE (atau Language Server). Selain itu, karena REST API sendiri memanfaatkan makna yang terkandung dalam kode error secara maksimal, kita dapat mengusulkan metode lain. Buatlah implementasi baru dari interface error, yaitu HttpError, yang menyimpan StatusCode dan Message. Lalu sediakan fungsi pembantu (helper) seperti berikut.
1err := httperror.BadRequest("wrong format")
Fungsi pembantu BadRequest akan mengembalikan HttpError dengan StatusCode diatur ke 400 dan Message diatur sesuai argumen yang diterima. Selain itu, tentu saja fungsi pembantu seperti NotImplement, ServiceUnavailable, Unauthorized, PaymentRequired, dan lainnya dapat ditelusuri serta ditambahkan melalui fitur autocomplete. Cara ini lebih cepat dibandingkan memeriksa dokumen desain yang sudah disiapkan setiap kali, dan lebih andal dibandingkan memasukkan kode error berupa angka secara manual setiap saat. Semua sudah ada di konstanta http.StatusCode? Ssst
Jumlah Penulisan Log Hasil yang Banyak
Ketika terjadi error, tentu saja kita akan mencatatnya dalam log. Saat mencatat log untuk pemanggilan API, serta untuk menentukan apakah permintaan berhasil atau gagal, menuliskan log pada setiap titik akhir yang mungkin terjadi sejak awal akan memperbanyak jumlah kode yang harus ditulis. Dengan membungkus handler itu sendiri, pengelolaan log dapat dilakukan secara terpusat.
Berikut adalah contoh pembungkus untuk 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 fungsionalitas chi.Router dapat digunakan secara langsung. Pada metode Get, sistem memeriksa apakah error yang dikembalikan adalah struct HttpError yang dihasilkan oleh fungsi pembantu yang baru saja diusulkan di atas, lalu meresponsnya dengan tepat. Jika error berupa error biasa, maka error tersebut diteruskan secara seragam ke fungsi callback error. Callback ini diterima melalui konstruktor.
Berikut adalah kode yang ditulis dengan memanfaatkan paket ini.
1package main
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "io"
8 "log"
9 "net/http"
10
11 "github.com/gosuda/httpwrap/httperror"
12 "github.com/gosuda/httpwrap/wrapper/chiwrap"
13)
14
15func main() {
16 ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
17 defer cancel()
18
19 r := chiwrap.NewRouter(func(err error) {
20 log.Printf("Router log test: Error occured: %v", err)
21 })
22 r.Get("/echo", func(writer http.ResponseWriter, request *http.Request) error {
23 name := request.URL.Query().Get("name")
24 if name == "" {
25 return httperror.BadRequest("name is required")
26 }
27
28 writer.Write([]byte("Hello " + name))
29 return nil
30 })
31
32 svr := http.Server{
33 Addr: ":8080",
34 Handler: r,
35 }
36 go func() {
37 if err := svr.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
38 log.Fatalf("Failed to start server: %v", err)
39 }
40 }()
41
42 <-ctx.Done()
43 svr.Shutdown(context.Background())
44}
Bagaimana menurut Anda? Jika kita hanya membuat HttpError sebagai fungsi pembantu dan mengembalikannya, maka pada cakupan yang lebih tinggi kita dapat memberikan respons dengan kode dan pesan error yang sesuai, sekaligus mendaftarkan callback agar setiap layanan dapat mencatat log yang sesuai. Jika diperlukan, sistem ini dapat diperluas dengan menggunakan RequestID dan lainnya sehingga pencatatan log yang lebih rinci dimungkinkan.
Pengiriman Pesan Error yang Jelas
Sebagai dokumen untuk keperluan ini, terdapat RFC7807. RFC7807 umumnya mendefinisikan dan menggunakan elemen-elemen berikut.
type: URI yang mengidentifikasi jenis error. Biasanya berupa dokumen yang menjelaskan error tersebut.title: Penjelasan singkat dalam satu baris mengenai jenis error yang terjadi.status: Sama dengan HTTP Status Code.detail: Penjelasan rinci mengenai error tersebut yang dapat dibaca oleh manusia.instance: URI tempat terjadinya error. Sebagai contoh, jika error terjadi padaGET /user/info, maka nilainya adalah/user/info.extensions: Elemen tambahan untuk menjelaskan error yang disusun dalam bentuk JSON Object.- Sebagai contoh, pada kasus
BadRequest, input pengguna dapat disertakan. - Atau pada kasus
TooManyRequest, waktu permintaan terbaru juga dapat disertakan.
- Sebagai contoh, pada kasus
Untuk memudahkan penggunaannya, buatlah berkas baru di dalam paket httperror, tempat HttpError berada, lalu buat struct RFC7807Error dan sediakan cara pembuatannya dengan pola method chaining.
1func NewRFC7807Error(status int, title, detail string) *RFC7807Error {
2 return &RFC7807Error{
3 Type: "about:blank", // Tipe default sesuai 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 { ... }
Nilai "about:blank" pada Type adalah nilai default. Nilai tersebut menunjukkan halaman yang tidak ada. Berikut adalah contoh pembuatan error untuk permintaan yang tidak valid.
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 yang terstruktur untuk pengguna dapat dibuat. Selain itu, untuk memanfaatkan router terpusat yang telah dibuat sebelumnya, kita dapat menyediakan method berikut.
1func (p *RFC7807Error) ToHttpError() *HttpError {
2 jsonBytes, err := json.Marshal(p)
3 if err != nil {
4 // Jika marshaling gagal, gunakan detail saja
5 return New(p.Status, p.Detail)
6 }
7 return New(p.Status, string(jsonBytes))
8}
Dengan menggunakan method tersebut secara langsung, contoh di atas dapat diubah menjadi seperti berikut.
1package main
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "io"
8 "log"
9 "net/http"
10
11 "github.com/gosuda/httpwrap/httperror"
12 "github.com/gosuda/httpwrap/wrapper/chiwrap"
13)
14
15func main() {
16 ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
17 defer cancel()
18
19 r := chiwrap.NewRouter(func(err error) {
20 log.Printf("Router log test: Error occured: %v", err)
21 })
22 r.Get("/echo", func(writer http.ResponseWriter, request *http.Request) error {
23 name := request.URL.Query().Get("name")
24 if name == "" {
25 return httperror.BadRequestProblem("name is required", "Bad User Input").
26 WithType("https://example.com/errors/validation").
27 WithInstance("/api/echo").
28 WithExtension("invalid_field", "name").
29 WithExtension("expected_format", "string").
30 WithExtension("actual_value", name).
31 ToHttpError()
32 }
33
34 writer.Write([]byte("Hello " + name))
35 return nil
36 })
37
38 svr := http.Server{
39 Addr: ":8080",
40 Handler: r,
41 }
42 go func() {
43 if err := svr.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
44 log.Fatalf("Failed to start server: %v", err)
45 }
46 }()
47
48 <-ctx.Done()
49 svr.Shutdown(context.Background())
50}
Kesimpulan
Dengan menangani error melalui router terpusat seperti ini, beban untuk memeriksa kode error dan menulis pesan error yang sesuai setiap kali dapat dikurangi. Selain itu, dengan memanfaatkan RFC7807 untuk menyediakan pesan error yang terstruktur, klien dapat lebih mudah memahami dan menangani error tersebut. Melalui pendekatan ini, penanganan error pada HTTP API yang ditulis dengan bahasa Go dapat dibuat lebih sederhana dan konsisten.
Kode pada artikel ini dapat dilihat di repositori gosuda/httpwrap.