Handling HTTP errors less cumbersomely + RFC7807
Overview
When creating http APIs in the Go language, error handling is often the most cumbersome part. 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 will likely not cause significant inconvenience. However, as the number of APIs increases and the internal logic becomes more complex, three aspects become bothersome.
- Returning appropriate error codes
- The large volume of result log writing
- Sending clear error messages
Body
Returning Appropriate Error Codes
Of course, number 1, returning appropriate error codes, is a personal complaint. Experienced developers will likely find the appropriate codes and insert them correctly each time, but developers like myself, who are still inexperienced, may struggle to consistently use suitable error codes as the logic becomes complex and the number of occurrences increases. There would be various methods for this, and the most representative one would be to design the API logic flow in advance and then write code to return appropriate errors. Just do that
However, this does not seem to be the optimal method for human developers who receive assistance from an IDE (or Language Server). Furthermore, as REST API itself maximizes the utilization of the meaning contained within error codes, another approach can be proposed. Create a new implementation of the error
interface called HttpError
to store StatusCode
and Message
. Then, provide a helper function 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 received as an argument. In addition to this, it would naturally be possible to look up and add helper functions such as NotImplement
, ServiceUnavailable
, Unauthorized
, PaymentRequired
, etc., using the auto-completion feature. This will be faster than checking the prepared design document every time, and more stable than inputting error codes numerically each time. They are all in the http.StatusCode
constants, you say? Shh
Large Volume of Result Log Writing
When an error occurs, logs are naturally recorded. When logging whether an API call and request were successful or failed, writing logs from the beginning to every anticipated exit point increases the amount of code to be written. By wrapping the handler itself, 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 contains chi.Router
internally and is configured to use the functionality of chi.Router
as is. Looking at the Get
method, it checks if the HttpError
struct returned by the helper function suggested just above was returned, then returns it appropriately, and if it is a general error
, it is uniformly passed to the error callback function. This callback is received through the constructor.
The following is code written utilizing 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 is it? Simply by creating and returning an HttpError
as a helper function, the upper scope can return an appropriate error code and message in the response, and it is possible to handle logging by registering a callback to record appropriate logs for each implemented service. If further functionality is needed, it can be extended to allow detailed logging using RequestID
, etc.
Sending Clear Error Messages
RFC7807 is a document for this purpose. RFC7807 primarily defines and uses the following elements.
type
: A URI that identifies the error type. It is primarily a document describing the error.title
: A one-line description of what the error is.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
,/user/info
would be its value.extensions
: Secondary elements configured in JSON Object format to further describe the error.- For example, in the case of
BadRequest
, user input might be included. - Or, in the case of
TooManyRequest
, the time of the most recent request might be included.
- For example, in the case of
To facilitate its use, a new file is created in the httperror
package, in the same location as HttpError
, and an RFC7807Error
struct is created, allowing it to be instantiated 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"
for Type
is the default value. It signifies a non-existent page. Below is an example of creating an error for a bad 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 created using simple method chaining. Additionally, to utilize 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 directly and modifying the example above results in the following.
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 using a centralized router in this manner for error handling, the burden of checking error codes and writing appropriate error messages each time can be reduced. Furthermore, by utilizing RFC7807 to provide structured error messages, clients can be assisted in understanding and processing errors. Through these methods, error handling for HTTP APIs written in the Go language can be made more convenient and consistent.
The code for this article can be found in the gosuda/httpwrap repository.