GoSuda

HTTP hatalarını daha az zahmetle ele almak + RFC7807

By snowmerak
views ...

Genel Bakış

Go dilinde http api oluşturulurken en zahmetli kısım hata yönetimidir. Buna tipik olarak aşağıdaki kod örneği verilebilir.

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}

Eğer API sayısı az ise, bu şekilde yazılması pek rahatsız edici olmayacaktır. Ancak, API sayısı arttıkça ve iç mantık karmaşıklaştıkça, üç husus rahatsız edici hale gelmektedir.

  1. Uygun hata kodu döndürme
  2. Çok sayıdaki sonuç logu kaydı
  3. Açık hata mesajı gönderme

Ana Metin

Uygun Hata Kodu Döndürme

Elbette 1 numara, yani uygun hata kodu döndürme, kişisel bir şikayetimdir. Tecrübeli geliştiriciler uygun kodu bulup her seferinde doğru şekilde yerleştirecektir; ancak benim gibi henüz acemi olan geliştiriciler, mantık karmaşıklaştıkça ve istek sayısı arttıkça, uygun hata kodlarını tutarlı bir şekilde kullanmakta zorluk yaşayabilirler. Bu hususta çeşitli yöntemler mevcuttur ve en tipik olanı, API mantık akışını önceden tasarlamak ve ardından uygun hataları döndürecek şekilde kodu yazmaktır. Bunu yapın

Fakat bu, IDE (veya Language Server) desteği alan insan geliştiriciler için en optimal yöntem gibi görünmemektedir. Ayrıca REST API'nin kendisi, hata kodlarında bulunan anlamı azami düzeyde kullandığından, başka bir yaklaşım da önerebiliriz. Bunun için, HttpError adında bir hata (error) arayüzü uygulayıcısı oluşturulur ve bu yapı, StatusCode ile Message bilgilerini saklar. Ve aşağıdaki yardımcı fonksiyon sağlanır:

1err := httperror.BadRequest("wrong format")

BadRequest yardımcısı fonksiyonu, StatusCode olarak 400 ve Message olarak kendisine verilen değeri ayarlayan bir HttpError döndürecektir. Bunun yanı sıra, otomatik tamamlama özelliği ile NotImplement, ServiceUnavailable, Unauthorized, PaymentRequired gibi yardımcısı fonksiyonlar sorgulanabilir ve eklenebilir. Bu yöntem, hazırlanan tasarım dokümanını sürekli kontrol etmekten daha hızlı ve her seferinde hata kodunu sayısal olarak girmekten daha tutarlı olacaktır. Tüm bunlar http.StatusCode sabitlerinde zaten mevcut, değil mi? Fısıltı

Çok Sayıda Sonuç Logu Kaydı

Hata oluştuğunda doğal olarak bir log kaydı tutulur. API çağrıldığında, isteğin başarılı olup olmadığı veya başarısız olduğu hakkında log kaydı tutulurken, başlangıçtan itibaren tüm olası sonlanma noktalarına log bırakmak, yazılması gereken kod miktarını artırır. Bu durum, ilgili handler'ı kapsayarak merkezi bir yönetimle gerçekleştirilebilir.

Aşağıda chi yönlendiricisini (router) saran bir örnek yer almaktadır.

 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}

Yönlendirici (Router) yapısı, chi.Router'ı bünyesinde barındırarak, chi.Router'ın işlevselliğini doğrudan kullanacak şekilde yapılandırılmıştır. Get metoduna bakıldığında, az önce önerilen yardımcısı fonksiyonun döndürdüğü HttpError yapısının döndürülüp döndürülmediği kontrol edilir, uygun şekilde geri dönüş yapılır ve eğer error ise, toplu olarak hata geri çağırma (callback) fonksiyonuna iletilir. Bu geri çağırma fonksiyonu, yapıcı (constructor) metot aracılığıyla girdi olarak alınır.

Aşağıda bu paketi kullanarak yazılmış kod bulunmaktadır.

 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}

Ne dersiniz? Sadece HttpError'u bir yardımcısı fonksiyon olarak oluşturup döndürerek, üst kapsamda (scope) uygun hata kodu ve mesajı ile yanıt dönerken, her bir uygulama servisine özel uygun log kaydını bırakabilmek için bir geri çağırma fonksiyonu kaydederek işlem yapılabilir. İhtiyaç duyulursa ek olarak genişletilerek, RequestID gibi unsurlar kullanılarak ayrıntılı kayıt tutulması mümkün olacaktır.

Açık Hata Mesajı Gönderme

Bunun için bir standart belge olarak RFC7807 mevcuttur. RFC7807 temel olarak aşağıdaki bileşenleri tanımlayarak kullanılır:

  • type: Hata türünü tanımlayan bir URI. Genellikle hata hakkında bilgi veren bir dokümandır.
  • title: Ne tür bir hata olduğuna dair tek satırlık açıklamadır.
  • status: HTTP Status Code ile aynıdır.
  • detail: İlgili hata hakkında insanın okuyabileceği ayrıntılı açıklamadır.
  • instance: Hatanın meydana geldiği URI'dir. Örneğin, GET /user/info sırasında bir hata oluştuysa, /user/info bu değer olacaktır.
  • extensions: JSON Object formatında yapılandırılmış hatayı açıklamak için kullanılan ikincil bileşenlerdir.
    • Örneğin, BadRequest durumunda kullanıcının girdileri dahil edilebilir.
    • Veya TooManyRequest durumunda en son istek zaman damgası dahil edilebilir.

Bunu kolayca kullanmak amacıyla, HttpError ile aynı konumda bulunan httperror paketinde yeni bir dosya oluşturulur ve RFC7807Error yapısı tanımlanır; bu yapı, metot zincirleme (method chaining) deseniyle oluşturulabilir hale getirilir.

 1func NewRFC7807Error(status int, title, detail string) *RFC7807Error {
 2	return &RFC7807Error{
 3		Type:   "about:blank", // RFC7807'ye göre varsayılan tür
 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 { ... }

Type alanındaki "about:blank" varsayılan değerdir. Bu, mevcut olmayan bir sayfayı ifade eder. Aşağıda hatalı bir istek için hata oluşturma örneği verilmiştir.

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")

Basit metot zincirleme ile kullanıcıya yönelik yapılandırılmış bir hata mesajı oluşturulabilir. Ayrıca, daha önce oluşturulan merkezi yönlendiriciden faydalanmak için aşağıdaki metot desteklenebilir:

1func (p *RFC7807Error) ToHttpError() *HttpError {
2	jsonBytes, err := json.Marshal(p)
3	if err != nil {
4		// Eğer kodlama başarısız olursa, sadece detayı kullanmaya geri dönülür
5		return New(p.Status, p.Detail)
6	}
7	return New(p.Status, string(jsonBytes))
8}

Bunu doğrudan kullanarak yukarıdaki örnek revize edildiğinde aşağıdaki gibi olacaktır.

 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}

Sonuç

Bu şekilde merkezi bir yönlendirici kullanarak hataları yönetmek, her seferinde hata kodlarını kontrol etme ve uygun hata mesajlarını yazma yükünü azaltabilir. Ayrıca, RFC7807'den faydalanarak yapılandırılmış hata mesajları sunmak, istemcinin hataları anlamasına ve işlemesine yardımcı olabilir. Bu yöntemler aracılığıyla, Go dilinde yazılmış HTTP API'lerinin hata yönetimi daha basit ve tutarlı hale getirilebilir.

İlgili makalenin kodlarına gosuda/httpwrap deposundan ulaşabilirsiniz.