Handling HTTP Errors with Less Annoyance + RFC7807
Overview
When creating an HTTP API in the Go language, error handling is often the most cumbersome aspect. A typical example of such code is as follows:
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 code in this manner may not present any significant inconvenience. However, as the number of APIs increases and internal logic becomes more complex, three issues tend to become prominent:
- Returning appropriate error codes.
- The large number of log entries generated.
- Transmitting clear error messages.
Main Discussion
Returning Appropriate Error Codes
Certainly, the first point, returning appropriate error codes, is a personal grievance. While experienced developers will consistently identify and insert the correct codes, less experienced developers, myself included, may encounter difficulties in regularly applying appropriate error codes as logic becomes more intricate and the frequency of operations increases. Various methods exist to address this; most notably, one could design the API logic flow in advance and then write code to return suitable errors. You should do that.
However, this does not appear to be the optimal approach for human developers who rely on IDEs (or Language Servers). Furthermore, given that REST APIs inherently maximize the utilization of meaning embedded in error codes, an alternative approach can be proposed. A new implementation of the error interface, named HttpError, can be created to store StatusCode and Message. This would then be supported by helper functions such as the following:
1err := httperror.BadRequest("wrong format")
The BadRequest helper function will return an HttpError with a StatusCode of 400 and the Message set to the value passed as an argument. Additionally, helper functions like NotImplement, ServiceUnavailable, Unauthorized, and PaymentRequired could naturally be looked up and added via autocompletion. This approach would be faster than repeatedly checking a pre-prepared design document and more stable than manually entering error codes each time. (Are they all in http.StatusCode constants? Shhh.)
Numerous Result Log Entries
When an error occurs, logs are naturally generated. When an API call is made and logs are recorded to indicate whether the request succeeded or failed, logging at every anticipated termination point from the outset results in a substantial increase in the amount of code to be written. This can be managed centrally by wrapping the handler itself.
The following is an example of wrapping a 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 encapsulates a chi.Router, enabling it to utilize all functionalities of chi.Router. In the Get method, it checks if the HttpError struct, which is returned by the helper function proposed above, is returned. If so, it is handled appropriately; otherwise, in the case of a generic error, it is uniformly passed to the error callback function. This callback is provided via the constructor.
The following code demonstrates the utilization of 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}
What do you think? By simply returning HttpError via a helper function, the upstream scope can respond with appropriate error codes and messages, while also registering callbacks to generate specific logs for each implemented service. Furthermore, if necessary, it can be extended to enable detailed logging using elements such as RequestID.
Transmitting Clear Error Messages
RFC7807 serves as a relevant document for this purpose. RFC7807 primarily defines and utilizes the following elements:
type: A URI that identifies the error type, typically a document explaining the error.title: A single-line description of the error.status: Identical to the HTTP Status Code.detail: A human-readable, detailed explanation of the error.instance: The URI where the error occurred. For example, if an error occurred atGET /user/info, then/user/infowould be its value.extensions: Ancillary elements structured as a JSON Object to further describe the error.- For example, in the case of a
BadRequest, user input might be included. - Or, in the case of
TooManyRequest, the timestamp of the most recent request might be included.
- For example, in the case of a
To facilitate its usage, a new file can be created in the httperror package, located alongside HttpError, to define the RFC7807Error struct, enabling its creation through 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 Type value "about:blank" is the default. It denotes a non-existent page. 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")
A structured error message for the user can be generated using simple method chaining. Furthermore, to leverage the centralized router mentioned previously, 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}
By directly utilizing this, the previous example can be 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 employing a centralized router for error handling, the burden of consistently verifying error codes and composing appropriate error messages can be mitigated. Furthermore, by leveraging RFC7807 to furnish structured error messages, clients can be assisted in comprehending and processing errors. This methodology can render error handling in Go-based HTTP APIs more straightforward and consistent.
The code discussed in this article can be found in the gosuda/httpwrap repository.