Tags: #go #http #json #api
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:
w.WriteHeader
hasn’t been called, then call it with http.StatusOK
.w.WriteHeader
has been called, then the status has already been sent to the client and it can’t now be changed.w.WriteHeader
have no effect. Whatever was first set, is what will be seen by the client.// 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.
}
}
// 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)
})
}
}