GoSuda

Menangani kesalahan HTTP dengan lebih sedikit merepotkan + RFC7807

By snowmerak
views ...

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.

  1. Pengembalian kode error yang tepat
  2. Banyaknya jumlah penulisan log hasil
  3. 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 pada GET /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.

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.