diff --git a/backend/internal/api/middleware/recovery.go b/backend/internal/api/middleware/recovery.go new file mode 100644 index 00000000..cc333725 --- /dev/null +++ b/backend/internal/api/middleware/recovery.go @@ -0,0 +1,27 @@ +package middleware + +import ( + "log" + "net/http" + "runtime/debug" + + "github.com/gin-gonic/gin" +) + +// Recovery logs panic information. When verbose is true it logs stacktraces +// and basic request metadata for debugging. +func Recovery(verbose bool) gin.HandlerFunc { + return func(c *gin.Context) { + defer func() { + if r := recover(); r != nil { + if verbose { + log.Printf("PANIC: %v\nRequest: %s %s\nHeaders: %v\nStacktrace:\n%s", r, c.Request.Method, c.Request.URL.String(), c.Request.Header, debug.Stack()) + } else { + log.Printf("PANIC: %v", r) + } + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + } + }() + c.Next() + } +} diff --git a/backend/internal/api/middleware/recovery_test.go b/backend/internal/api/middleware/recovery_test.go new file mode 100644 index 00000000..20a88a49 --- /dev/null +++ b/backend/internal/api/middleware/recovery_test.go @@ -0,0 +1,70 @@ +package middleware + +import ( + "bytes" + "log" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestRecoveryLogsStacktraceVerbose(t *testing.T) { + old := log.Writer() + buf := &bytes.Buffer{} + log.SetOutput(buf) + defer log.SetOutput(old) + + router := gin.New() + router.Use(Recovery(true)) + router.GET("/panic", func(c *gin.Context) { + panic("test panic") + }) + + req := httptest.NewRequest(http.MethodGet, "/panic", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected status 500, got %d", w.Code) + } + + out := buf.String() + if !strings.Contains(out, "PANIC: test panic") { + t.Fatalf("log did not include panic message: %s", out) + } + if !strings.Contains(out, "Stacktrace:") { + t.Fatalf("verbose log did not include stack trace: %s", out) + } +} + +func TestRecoveryLogsBriefWhenNotVerbose(t *testing.T) { + old := log.Writer() + buf := &bytes.Buffer{} + log.SetOutput(buf) + defer log.SetOutput(old) + + router := gin.New() + router.Use(Recovery(false)) + router.GET("/panic", func(c *gin.Context) { + panic("brief panic") + }) + + req := httptest.NewRequest(http.MethodGet, "/panic", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected status 500, got %d", w.Code) + } + + out := buf.String() + if !strings.Contains(out, "PANIC: brief panic") { + t.Fatalf("log did not include panic message: %s", out) + } + if strings.Contains(out, "Stacktrace:") { + t.Fatalf("non-verbose log unexpectedly included stacktrace: %s", out) + } +}