From e01db86fb6d74cc687880367a479bd07dbda4f4f Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Tue, 28 Apr 2026 15:10:55 +0800 Subject: [PATCH] feat(http/mw): recover middleware with structured panic log --- internal/transport/http/middleware/recover.go | 44 +++++++++++++++++++ .../transport/http/middleware/recover_test.go | 35 +++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 internal/transport/http/middleware/recover.go create mode 100644 internal/transport/http/middleware/recover_test.go diff --git a/internal/transport/http/middleware/recover.go b/internal/transport/http/middleware/recover.go new file mode 100644 index 0000000..611b88a --- /dev/null +++ b/internal/transport/http/middleware/recover.go @@ -0,0 +1,44 @@ +package middleware + +import ( + "context" + "log/slog" + "net/http" + "runtime/debug" +) + +// Recover returns HTTP middleware that traps panics raised by downstream +// handlers. When a panic is intercepted it is logged via log with the +// recovered value, request path, request ID (if present in context) and +// the full stack trace, after which a 500 response is written using a +// stable JSON envelope so clients see a predictable shape. +// +// Without this middleware a panic would unwind into net/http and abort +// the connection without any structured signal, making post-mortem +// debugging difficult. +func Recover(log *slog.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer recoverPanic(r.Context(), log, w, r.URL.Path) + next.ServeHTTP(w, r) + }) + } +} + +// recoverPanic is the deferred body of Recover. Splitting it out (rather +// than declaring an inline closure) lets us pass ctx explicitly so +// linters can see the context propagation and keeps the middleware +// itself readable. +func recoverPanic(ctx context.Context, log *slog.Logger, w http.ResponseWriter, path string) { + rec := recover() + if rec == nil { + return + } + log.LogAttrs(ctx, slog.LevelError, "panic recovered", + slog.Any("panic", rec), + slog.String("path", path), + slog.String("stack", string(debug.Stack())), + slog.String("request_id", RequestIDFromContext(ctx)), + ) + http.Error(w, `{"code":"internal","message":"internal server error"}`, http.StatusInternalServerError) +} diff --git a/internal/transport/http/middleware/recover_test.go b/internal/transport/http/middleware/recover_test.go new file mode 100644 index 0000000..f0159ae --- /dev/null +++ b/internal/transport/http/middleware/recover_test.go @@ -0,0 +1,35 @@ +package middleware + +import ( + "bytes" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRecover_PanicReturns500(t *testing.T) { + var buf bytes.Buffer + log := slog.New(slog.NewJSONHandler(&buf, nil)) + + h := Recover(log)(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + panic("boom") + })) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil)) + require.Equal(t, http.StatusInternalServerError, rec.Code) + require.Contains(t, buf.String(), "boom") +} + +func TestRecover_NoPanic_PassThrough(t *testing.T) { + log := slog.New(slog.NewJSONHandler(&bytes.Buffer{}, nil)) + h := Recover(log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil)) + require.Equal(t, http.StatusNoContent, rec.Code) +}