GoSuda

HTTP Hatalarını Daha Az Zahmetle İşleme + RFC7807

By snowmerak views ...

Genel Bakış

Go dilinde HTTP API oluştururken en zahmetli kısım hata yönetimidir. Tipik olarak aşağıdaki gibi bir kod bulunur.

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}

API sayısı az olduğunda, bu şekilde yazmak pek rahatsızlık vermeyebilir. Ancak API sayısı arttıkça ve iç mantık karmaşıklaştıkça üç nokta rahatsız edici hale gelir.

  1. Uygun hata kodunun döndürülmesi
  2. Çok sayıda sonuç günlüğünün yazılması
  3. Açık hata mesajının iletilmesi

Ana Bölüm

Uygun Hata Kodunun Döndürülmesi

Elbette birinci madde, yani uygun hata kodunun döndürülmesi, benim kişisel şikayetim sayılabilir. Deneyimli bir geliştirici uygun kodu bulup her seferinde doğru şekilde yerleştirecektir; ancak ben de dahil olmak üzere henüz tecrübesiz geliştiriciler, mantık karmaşıklaştıkça ve çağrı sayısı arttıkça uygun hata kodlarını tutarlı biçimde kullanmakta zorlanabilir. Bunun için birçok yöntem olabilir; en yaygını, API mantık akışını önceden tasarlayıp uygun hatayı döndürecek şekilde kodu yazmaktır. Bunu yapın

Ancak bu yaklaşım, IDE (veya Language Server) yardımından yararlanan insan geliştiriciler için en uygun yöntem gibi görünmemektedir. Ayrıca REST API'nin kendisi, hata kodlarında taşınan anlamdan azami ölçüde yararlanmaya dayandığından, farklı bir yöntem önerilebilir. StatusCode ve Message değerlerini saklayan, error arayüzünü uygulayan HttpError adında yeni bir tür oluşturulur. Ardından aşağıdaki gibi yardımcı fonksiyonlar sağlanır.

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

BadRequest yardımcı fonksiyonu, StatusCode değerini 400, Message değerini ise argüman olarak alınan değere ayarlanmış bir HttpError döndürecektir. Bunun dışında elbette NotImplement, ServiceUnavailable, Unauthorized, PaymentRequired gibi yardımcı fonksiyonlar da otomatik tamamlama özelliğiyle aranabilir ve eklenebilir. Bu yöntem, hazırlanmış bir tasarım belgesini her seferinde kontrol etmekten daha hızlı olacak; her seferinde sayısal hata kodunu elle girmekten ise daha güvenilir olacaktır. http.StatusCode sabitlerinde zaten hepsi var mı? Sessiz olun

Çok Sayıda Sonuç Günlüğünün Yazılması

Hata oluştuğunda elbette günlük kaydı tutulur. API çağrıldığında ve isteğin başarılı mı yoksa başarısız mı olduğuna dair günlük yazılırken, baştan itibaren tüm olası çıkış noktalarına günlük eklemek yazılacak kod miktarını artırır. Bunun yerine handler'ın kendisini bir kez sarmalayarak günlük yönetimini merkezi hale getirebiliriz.

Aşağıda chi router'ını saran bir örnek bulunmaktadı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}

Router yapısı chi.Router'ı içinde tutar; bu nedenle chi.Router'ın işlevlerini olduğu gibi kullanabilecek şekilde yapılandırılmıştır. Get metoduna bakıldığında, yukarıda önerilen yardımcı fonksiyonların döndürdüğü HttpError yapısının döndürülüp döndürülmediği kontrol edilir ve uygun şekilde yanıt verilir; error durumunda ise hepsi topluca hata callback fonksiyonuna iletilir. Bu callback, kurucu fonksiyon aracılığıyla alınır.

Aşağıda bu paketi kullanarak yazılmış kod yer almaktadı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}

Nasıl buldunuz? Yalnızca HttpError'ı yardımcı fonksiyonlarla oluşturup döndürdüğünüzde, üst kapsamda uygun hata kodu ve mesajıyla yanıt verilebilir; ayrıca her servis uygulaması için uygun günlüğü tutacak bir callback kaydedilerek işlem yapılabilir. Gerekirse genişleterek RequestID gibi bilgilerle ayrıntılı günlükleme de yapılabilir.

Açık Hata Mesajının İletilmesi

Bu amaçla kullanılabilecek belge olarak RFC7807 bulunmaktadır. RFC7807 genellikle aşağıdaki öğeleri tanımlayarak kullanılır.

  • type: Hata türünü tanımlayan URI. Genellikle hatayı açıklayan bir belgedir.
  • title: Hatanın ne olduğunu belirten kısa bir açıklamadır.
  • status: HTTP Status Code ile aynıdır.
  • detail: İlgili hata hakkında insanların okuyabileceği ayrıntılı açıklamadır.
  • instance: Hatanın oluştuğu URI'dir. Örneğin GET /user/info isteğinde hata oluştuysa, bu değer /user/info olacaktır.
  • extensions: Hatayı açıklamak için kullanılan, JSON Object biçiminde oluşturulan yardımcı öğedir.
    • Örneğin BadRequest durumunda kullanıcının girdisi yer alabilir.
    • Ya da TooManyRequest durumunda en son istek zamanı yer alabilir.

Bunu kolayca kullanabilmek için HttpError ile aynı konumda bulunan httperror paketine yeni bir dosya eklenir, RFC7807Error yapısı oluşturulur ve metot zincirleme (method chaining) deseniyle oluşturulabilmesi sağlanır.

 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 { ... }

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

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 bir metot zincirleme ile kullanıcıya yönelik yapılandırılmış bir hata mesajı oluşturulabilir. Ayrıca yukarıda önce yazdığımız merkezi router'ı kullanabilmek için aşağıdaki metot da desteklenebilir.

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}

Bu metot doğrudan kullanılarak yukarıdaki örnek düzenlendiğinde sonuç şu şekilde olur.

 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ç

Hataları bu şekilde merkezi bir router ile işlediğinizde, her seferinde hata kodunu kontrol etme ve uygun hata mesajını yazma yükünü azaltabilirsiniz. Ayrıca RFC7807 kullanılarak yapılandırılmış hata mesajları sunulması, istemcinin hatayı anlamasına ve işlemesine yardımcı olabilir. Bu yöntemlerle Go dilinde yazılmış HTTP API'lerinde hata yönetimini daha pratik ve tutarlı hale getirebilirsiniz.

Bu yazının kodlarını gosuda/httpwrap deposunda inceleyebilirsiniz.