Handling HTTP Errors with Less Hassle + RFC7807

Overview
When creating HTTP APIs in the Go language, the most tedious part is error handling. A typical example looks like this.
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}
If there are only a few APIs, writing it this way will not be particularly inconvenient. However, as the number of APIs grows and the internal logic becomes more complex, three things become bothersome.
- Returning appropriate error codes
- The large number of result logs to write
- Sending clear error messages
Main Body
Returning Appropriate Error Codes
Of course, the first point, returning appropriate error codes, is my personal complaint. A seasoned developer would find the appropriate code and insert it correctly every time, but I, and other developers who are still inexperienced, may struggle to consistently use the appropriate error codes as the logic becomes complex and the number of cases increases. There are likely several ways to address this, and the most representative is to design the API logic flow in advance and then write the code to return the appropriate errors. Just do it that way
However, this does not appear to be the optimal approach for human developers who rely on an IDE (or Language Server). Also, since the REST API itself makes maximum use of the meaning conveyed by error codes, another approach can be proposed. Create a new implementation of the error interface called HttpError that stores StatusCode and Message. Then provide helper functions such as the following.
1err := httperror.BadRequest("wrong format")
The BadRequest helper function will return an HttpError with StatusCode set to 400 and Message set to the value passed as an argument. In addition, helper functions such as NotImplement, ServiceUnavailable, Unauthorized, PaymentRequired, and so on can of course be looked up and added through autocompletion. This is faster than checking a prepared design document every time, and more reliable than entering error codes as numbers each time. They are all in the http.StatusCode constants? Shh
The Large Number of Result Logs to Write
When an error occurs, logs are naturally recorded. When logging whether an API was invoked and whether a request succeeded or failed, logging at every anticipated exit point from the beginning increases the amount of code to write. By wrapping the handler itself once, this can be managed centrally.
The following is an example of wrapping the chi router.
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}
The router struct holds a chi.Router internally, so it is configured to use the functionality of chi.Router as is. Looking at the Get method, it checks whether the HttpError struct returned by the helper functions proposed above was returned and responds appropriately, and in the case of a plain error, it uniformly passes it to the error callback function. This callback is received through the constructor.
The following is code written using this package.
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}
How does this look? If you simply create an HttpError through a helper function and return it, the upper scope can respond with an appropriate error code and message, while each implementing service can register a callback to record appropriate logs. If needed, it can be extended to enable detailed logging using things like RequestID.
Sending Clear Error Messages
The document for this purpose is RFC7807. RFC7807 mainly defines and uses the following elements.
type: A URI that identifies the error type. Mainly a document that describes the error.title: A one-line description of what the error is.status: Identical to the HTTP Status Code.detail: A detailed, human-readable explanation of the error.instance: The URI where the error occurred. For example, if an error occurred atGET /user/info, the value would be/user/info.extensions: Auxiliary elements composed as a JSON Object to describe the error.- For example, in the case of
BadRequest, the user's input may be included. - Or, in the case of
TooManyRequest, the time of the most recent request may be included.
- For example, in the case of
To make this easy to use, create a new file in the httperror package, in the same location as HttpError, define an RFC7807Error struct, and allow it to be created using a method chaining pattern.
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 { ... }
The "about:blank" value for Type is the default. It means a page that does not exist. Below is an example of creating an error for an invalid request.
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")
With simple method chaining, you can create a structured error message for the user. In addition, to use the centralized router written earlier, the following method can be supported.
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}
Using this as is, the example above is modified as follows.
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}
Conclusion
By handling errors with a centralized router in this way, the burden of checking error codes and writing appropriate error messages every time can be reduced. Also, by providing structured error messages using RFC7807, clients can be helped to understand and handle errors. Through these methods, error handling for HTTP APIs written in the Go language can be made simpler and more consistent.
The code for this article can be found in the gosuda/httpwrap repository.