diff --git a/recovery/doc.go b/recovery/doc.go new file mode 100644 index 0000000..dac9a68 --- /dev/null +++ b/recovery/doc.go @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package recovery provides panic recovery middleware for HTTP handlers. +// +// The middleware recovers from panics in HTTP handlers and returns a +// 500 Internal Server Error response to the client. This prevents a single +// panicking request from crashing the entire server. +// +// # Basic Usage +// +// mux := http.NewServeMux() +// mux.HandleFunc("/", handler) +// wrappedMux := recovery.Middleware(mux) +// http.ListenAndServe(":8080", wrappedMux) +// +// # Stability +// +// This package is Beta stability. The API may have minor changes before +// reaching stable status in v1.0.0. +package recovery diff --git a/recovery/recovery.go b/recovery/recovery.go new file mode 100644 index 0000000..5caaf45 --- /dev/null +++ b/recovery/recovery.go @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package recovery + +import ( + "net/http" +) + +// Middleware is an HTTP middleware that recovers from panics. +// When a panic occurs, it returns a 500 Internal Server Error response +// to the client, preventing the panic from crashing the server. +// +// TODO(#7): Add configurable logging support once common logging is +// established across ToolHive. Currently panics are silently recovered. +func Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if recover() != nil { + // TODO(#7): Log panic value and stack trace + // stack := debug.Stack() + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } + }() + next.ServeHTTP(w, r) + }) +} diff --git a/recovery/recovery_test.go b/recovery/recovery_test.go new file mode 100644 index 0000000..e08c952 --- /dev/null +++ b/recovery/recovery_test.go @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package recovery + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMiddleware_NoPanic(t *testing.T) { + t.Parallel() + + // Create a test handler that does not panic + testHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("success")) + }) + + // Wrap with recovery middleware + wrappedHandler := Middleware(testHandler) + + // Create test request + req := httptest.NewRequest(http.MethodGet, "/test", nil) + rec := httptest.NewRecorder() + + // Execute request + wrappedHandler.ServeHTTP(rec, req) + + // Verify response + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "success", rec.Body.String()) +} + +func TestMiddleware_RecoverFromPanic(t *testing.T) { + t.Parallel() + + // Create a test handler that panics + testHandler := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + panic("test panic") + }) + + // Wrap with recovery middleware + wrappedHandler := Middleware(testHandler) + + // Create test request + req := httptest.NewRequest(http.MethodGet, "/test", nil) + rec := httptest.NewRecorder() + + // Execute request - should not panic + wrappedHandler.ServeHTTP(rec, req) + + // Verify 500 Internal Server Error response + assert.Equal(t, http.StatusInternalServerError, rec.Code) + assert.Contains(t, rec.Body.String(), "Internal Server Error") +} + +func TestMiddleware_PreservesRequestContext(t *testing.T) { + t.Parallel() + + type contextKey string + const key contextKey = "test-key" + const value = "test-value" + + var receivedValue string + + // Create a test handler that reads from context + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if v := r.Context().Value(key); v != nil { + receivedValue = v.(string) + } + w.WriteHeader(http.StatusOK) + }) + + // Wrap with recovery middleware + wrappedHandler := Middleware(testHandler) + + // Create test request with context value + req := httptest.NewRequest(http.MethodGet, "/test", nil) + ctx := context.WithValue(req.Context(), key, value) + req = req.WithContext(ctx) + rec := httptest.NewRecorder() + + // Execute request + wrappedHandler.ServeHTTP(rec, req) + + // Verify context was preserved + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, value, receivedValue) +}