« Back to Index

Go: httpx.WriteJSON

View original Gist on GitHub

Tags: #go #http #json #api

1. README.md

Here is some problematic code…

func WriteJSON(l *slog.Logger, w http.ResponseWriter, r *http.Request, code int, v any) {
	ctx := r.Context()
	w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(code)

	if err := json.NewEncoder(w).Encode(v); err != nil {
		l.LogAttrs(ctx, slog.LevelError, "encode_json_response", slog.Any("err", err))
		w.WriteHeader(http.StatusInternalServerError)
		// w.Write([]byte("some response data"))
		fmt.Fprintf(w, `{"error": %q}`, err)
		return
	}
}

It’s problematic because an error encoding the JSON response will result in a 2xx status code but an error JSON message.

This is because of how http.ResponseWriter.Write works:

internal-httpx-write.go

// EXAMPLES
//
// ERROR RESPONSE:
//    response := ErrorResponse{Message: "error reading request body", Details: err.Error()}
//    httpx.WriteJSON(l, w, r, http.StatusBadRequest, response)
//
// SUCCESS RESPONSE:
//    response := map[string]string{"message": "updated order status to trigger certificate issuance"}
//    httpx.WriteJSON(l, w, r, http.StatusOK, response)

package httpx

import (
	"bytes"
	"encoding/json"
	"fmt"
	"log/slog"
	"net/http"
)

// WriteJSON encodes v as JSON and writes to w.
// It ensures the correct status code is written even if JSON encoding fails.
// Will write a [http.StatusInternalServerError] if there is an error.
// Otherwise, it'll write the JSON response with specified code status.
//
// WARNING: The response status code is explicitly sent before the body.
//
// We have to do this because we don't want the first call to
// [http.ResponseWriter.Write] to call `WriteHeader(http.StatusOK)`.
//
// This means there is the potential for the incorrect status code to be sent.
// If, the call to [bytes.Buffer.WriteTo] fails, then we've already set the
// response status code. We now can't change the status, as Go ignores
// subsequent calls to [http.ResponseWriter.WriteHeader]. The best we can do is
// catch and log the error.
func WriteJSON(l *slog.Logger, w http.ResponseWriter, r *http.Request, code int, v any) {
	ctx := r.Context()
	w.Header().Set("Content-Type", "application/json")

	var buf bytes.Buffer
	if err := json.NewEncoder(&buf).Encode(v); err != nil {
		l.LogAttrs(ctx, slog.LevelError, "encode_json_response", slog.Any("err", err))
		w.WriteHeader(http.StatusInternalServerError)
		fmt.Fprintf(w, `{"error": %q}`, err)
		return
	}

	w.WriteHeader(code)

	if _, err := buf.WriteTo(w); err != nil {
		l.LogAttrs(ctx, slog.LevelError, "write_buffered_response", slog.Any("err", err))
		fmt.Fprintf(w, `{"error": %q}`, err)
		return
		// Alternatively, instead of writing the error and returning...
		// panic(http.ErrAbortHandler)
		// ...but you should probably have some Panic Recovery middleware in your stack.
	}
}

middleware.go

// PanicRecovery recovers from panics in an HTTP handler.
// It records a log line and reports a metric, then re-raises the panic so
// [http.Server] can handle the default recovery behaviour.
func PanicRecovery(l *slog.Logger, m *metrics.Metrics) func(next http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			// IMPORTANT: Create a scoped logger to avoid memory leaks.
			sl := l.With(
				slog.Group("request",
					slog.String("method", r.Method),
					slog.String("path", r.URL.Path),
				),
			)
			ctx := r.Context()

			defer func() {
				if rec := recover(); rec != nil {
					// [http.ErrAbortHandler] is a sentinel panic value to abort
					// a handler. While any panic from ServeHTTP aborts the
					// response to the client, panicking with ErrAbortHandler
					// also suppresses logging of a stack trace to the server's
					// error log. We catch the panic early so we can issue a
					// custom log and metric call, then re-raise the panic.
					panicType := "Unknown"
					if rec == http.ErrAbortHandler {
						panicType = "ErrAbortHandler"
					}

					sl.LogAttrs(ctx, slog.LevelInfo, "panic_recovered",
						slog.Any("panic", panicType),
						slog.String("stack_trace", string(debug.Stack())),
					)
					m.Count("api_panic_countervecs_total", "panic="+panicType)

					// We re-raise the panic so that the net/http server's
					// default panic handler can take over. This ensures the
					// server terminates the request gracefully.
					panic(rec)
				}
			}()
			next.ServeHTTP(w, r)
		})
	}
}