Skip to content

Instantly share code, notes, and snippets.

@Integralist
Last active May 29, 2025 13:41
Show Gist options
  • Save Integralist/c528f499d892cb0d74f7e037d5856358 to your computer and use it in GitHub Desktop.
Save Integralist/c528f499d892cb0d74f7e037d5856358 to your computer and use it in GitHub Desktop.
Go: httpx.WriteJSON #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:

  • If w.WriteHeader hasn't been called, then call it with http.StatusOK.
  • If w.WriteHeader has been called, then the status has already been sent to the client and it can't now be changed.
  • This means repeated calls to 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)
})
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment