Aug 25, 2024
http.Handlers and error handling in Golang
5 min read
HTTP handlers in Go are relatively easy to write but you can fall into a few issues potentially. Take this very simple handler below as an example:
func (wo *workspaceHandler) list(w http.ResponseWriter, r *http.Request) {
ctx, span, rid := getTracer(r.Context(), r, "list")
defer span.End()
user := getUser(r.Context())
workspace := getWorkspace(r.Context())
logger := wo.logger.WithField("request_id", rid).
WithField("method", "list").
WithField("user", user.ID).
WithField("workspace_id = ?", workspace.ID)
logger.Debug("Listing all workspaces")
workspaces, err := wo.workspaceRepo.List(ctx, user)
if err != nil {
logger.WithError(err).Error("could not list workspaces ")
span.RecordError(err)
span.SetStatus(codes.Error, "could not list workspaces")
_ = render.Render(w, r, newAPIError(http.StatusInternalServerError,
"an error occurred while listing your workspaces"))
return
}
if err := someOtherOperation(); err != nil {
logger.WithError(err).Error("could not list workspaces ")
span.RecordError(err)
span.SetStatus(codes.Error, "could not list workspaces")
_ = render.Render(w, r, newAPIError(http.StatusInternalServerError,
"an error occurred while listing your workspaces"))
return
}
span.SetStatus(codes.Ok, "successfully listed workspace")
_ = render.Render(w, r, &listWorkspaces{
Workspaces: workspaces,
APIStatus: newAPIStatus(http.StatusOK, "Fetched list of your workspaces"),
CurrentWorkspace: currentWorkspace,
})
}
Can you spot what can potentially go wrong here? If you cannot, I will point out two issues:
Every time, we encounter an error, you have always to add a naked
return
after you have sent the HTTP response. Else the rest of the handler will continue to run and you will end up with an unintended state. You will also write the response which is usually an error by the wayCode repetition. For every error path, you have to record the status of the span with
span.SetStatus
, you also have to initialize theuser
andworkspace
variables amongst others. If this was a fairly non-trivial project, you might have maybe 8-10 handlers. Having to do the same thing across all of them isn't a great idea especially when you think of changing specific things to how your tracing or logging is set up
How I avoid this
The first thing I do is set up enum types.
type Status uint8
const (
StatusSuccess Status = iota
StatusFailed
)
Please use
go-enum
library instead of writing by hand in production software.
Once I have my enums set up, I can then go ahead to define a custom http handler
type. While it is a custom type, please note it will still satisfy the standard
http.HandlerFunc
type from the standard library and thus can be used with various
routers in the ecosystem.
type HTTPHandler func(
context.Context,
trace.Span,
*logrus.Entry,
http.ResponseWriter,
*http.Request) (render.Renderer, Status)
func WrapHTTPHandler(
handler HTTPHandler,
cfg config.Config,
spanName string) http.HandlerFunc {
h, _ := os.Hostname()
return func(w http.ResponseWriter, r *http.Request) {
ctx, span, rid := getTracer(r.Context(), r, spanName, cfg.Otel.IsEnabled)
defer span.End()
logger := logrus.WithField("host", h).
WithField("app", "http").
WithField("method", spanName).
WithField("request_id", rid).
WithContext(ctx)
// add common log labels
if doesWorkspaceExistInContext(r.Context()) {
logger = logger.WithField("workspace_id", getWorkspaceFromContext(r.Context()).ID)
}
if doesUserExistInContext(r.Context()) {
logger = logger.WithField("user_id", getUserFromContext(r.Context()).ID)
}
resp, status := handler(ctx, span, logger, w, r)
switch status {
case StatusFailed:
span.SetStatus(codes.Error, "")
case StatusSuccess:
span.SetStatus(codes.Ok, "")
default:
_ = render.Render(w, r, newAPIStatus(500, "unknown error"))
return
}
_ = render.Render(w, r, resp)
}
}
We have introduced a new HTTPHandler
type which our handlers must
implement right now instead of http.HandlerFunc
, but to keep it
compatible with the rest of the ecosystem, there is a wrapper that
allows us to match http.HandlerFunc
still.
It is also important to note that both issues I mentioned above have been resolved. We only have one place where we set up Opentelemetry tracing now across our handlers. Even if we have 1,000 routes and handlers, we only need to change in one place. Same thing with the logging details.
Okay, what does an handler look like now
func (a *authHandler) Login(
ctx context.Context,
span trace.Span,
logger *logrus.Entry,
w http.ResponseWriter,
r *http.Request) (render.Renderer, Status) {
provider := chi.URLParam(r, "provider")
logger = logger.WithField("provider", provider)
span.SetAttributes(attribute.String("auth.provider", provider))
logger.Debug("Authenticating user")
if provider != "google" || provider != "github" {
return newAPIStatus(http.StatusBadRequest, "unspported provider"), StatusFailed
}
// you filled this somehow with data from the req body
req := new(authenticateUserRequest)
if err := req.Validate(); err != nil {
return newAPIStatus(http.StatusBadRequest, err.Error()), StatusFailed
}
token, err := a.googleCfg.Validate(ctx, socialauth.ValidateOptions{
Code: req.Code,
})
if err != nil {
logger.WithError(err).Error("could not exchange token")
return newAPIStatus(400, "could not verify your sign in with Google"), StatusFailed
}
user := &User{
Email: Email(u.Email),
FullName: u.Name,
}
err = a.userRepo.Create(ctx, user)
if err != nil {
logger.WithError(err).Error("an error occurred while creating user")
return newAPIStatus(500, "an error occurred while creating user"), StatusFailed
}
resp := createdUserResponse{
User: user,
APIStatus: newAPIStatus(http.StatusOK, "user Successfully created"),
}
return resp, StatusSuccess
}
In this handler, you can see that a return value is always enforced hence mitigating the need to always verify all your handlers did the right thing and that you have no leaks.
In this instance, the OTEL span has been used to add more metadata/attributes to the span. Same thing as the logger, it has been used to generate more useful logs. That really is the essence of this pattern to me. It provides a base template to prevent repetition across the board but allows each handler to go further with whatever custom details they'd love to implement themselves.
It is also important to know you can extend this as much as you want or as much as it makes sense for your use-case
How do I use this with my router?
// Using the chi router as an example
r.Post("/", WrapHTTPHandler(auth.Login, cfg, "Auth.Login"))
Okay, what exactly does getTracer do? I have seen it a few times
func getTracer(ctx context.Context, r *http.Request,
operationName string, isEnabled bool) (context.Context, trace.Span, string) {
rid := retrieveRequestID(r)
if !isEnabled {
ctx, span := noopTracer.Start(ctx, operationName)
return ctx, span, rid
}
ctx, span := tracer.Start(ctx, operationName)
span.SetAttributes(attribute.String("request_id", rid))
return ctx, span, rid
}