From 82192c07bb4a7b9bd41628b6b5541f76e09e16b1 Mon Sep 17 00:00:00 2001 From: dkarczmarski Date: Mon, 23 Feb 2026 20:17:44 +0100 Subject: [PATCH 1/6] feat(httpx): add optional X-Error-Message header to ErrorSink --- pkg/httpx/httpx_utils.go | 46 +++++++---- pkg/httpx/httpx_utils_test.go | 78 +++++++++++++++++++ pkg/router/handlers/execution_handler_test.go | 70 ++++++++--------- pkg/router/router.go | 2 +- 4 files changed, 146 insertions(+), 50 deletions(-) create mode 100644 pkg/httpx/httpx_utils_test.go diff --git a/pkg/httpx/httpx_utils.go b/pkg/httpx/httpx_utils.go index bf2df38..7774284 100644 --- a/pkg/httpx/httpx_utils.go +++ b/pkg/httpx/httpx_utils.go @@ -83,7 +83,8 @@ type stackTracer interface { // ErrorSink returns a terminal handler that logs errors and writes appropriate HTTP responses. // If logger is nil, log.Default() is used. -func ErrorSink(logger *log.Logger) func(WebHandler) http.Handler { +// If withErrorHeader is true, the error message is added to the X-Error-Message HTTP header. +func ErrorSink(logger *log.Logger, withErrorHeader bool) func(WebHandler) http.Handler { if logger == nil { logger = log.Default() } @@ -95,22 +96,15 @@ func ErrorSink(logger *log.Logger) func(WebHandler) http.Handler { return } - status := http.StatusInternalServerError - msg := "" - withStackTrace := true + status, msg, withStackTrace := extractErrorInfo(err) - var sc statusCoder - if errors.As(err, &sc) { - status = sc.HTTPStatus() - - if mc, ok := sc.(messageCarrier); ok { - msg = mc.Message() + if withErrorHeader { + headerMsg := msg + if headerMsg == "" { + headerMsg = err.Error() } - } - var st stackTracer - if errors.As(err, &st) { - withStackTrace = st.StackTrace() + responseWriter.Header().Set("X-Error-Message", headerMsg) } if status >= http.StatusInternalServerError && withStackTrace { @@ -131,3 +125,27 @@ func ErrorSink(logger *log.Logger) func(WebHandler) http.Handler { }) } } + +func extractErrorInfo(err error) (int, string, bool) { + status := http.StatusInternalServerError + msg := "" + withStackTrace := true + + var sc statusCoder + + if errors.As(err, &sc) { + status = sc.HTTPStatus() + + if mc, ok := sc.(messageCarrier); ok { + msg = mc.Message() + } + } + + var st stackTracer + + if errors.As(err, &st) { + withStackTrace = st.StackTrace() + } + + return status, msg, withStackTrace +} diff --git a/pkg/httpx/httpx_utils_test.go b/pkg/httpx/httpx_utils_test.go new file mode 100644 index 0000000..6bd2431 --- /dev/null +++ b/pkg/httpx/httpx_utils_test.go @@ -0,0 +1,78 @@ +package httpx_test + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/dkarczmarski/webcmd/pkg/httpx" +) + +func TestErrorSink(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + err error + withErrorHeader bool + expectedHeader string + expectedStatus int + }{ + { + name: "No error header, normal error", + err: errors.New("standard error"), + withErrorHeader: false, + expectedHeader: "", + expectedStatus: http.StatusInternalServerError, + }, + { + name: "With error header, normal error", + err: errors.New("standard error"), + withErrorHeader: true, + expectedHeader: "standard error", + expectedStatus: http.StatusInternalServerError, + }, + { + name: "With error header, WebError with message", + err: httpx.NewWebError(errors.New("internal"), http.StatusBadRequest, "public message"), + withErrorHeader: true, + expectedHeader: "public message", + expectedStatus: http.StatusBadRequest, + }, + { + name: "With error header, WebError without message", + err: httpx.NewWebError(errors.New("internal error message"), http.StatusBadRequest, ""), + withErrorHeader: true, + expectedHeader: "internal error message", + expectedStatus: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + handler := httpx.WebHandlerFunc(func(_ http.ResponseWriter, _ *http.Request) error { + return tt.err + }) + + sink := httpx.ErrorSink(nil, tt.withErrorHeader) + h := sink(handler) + + req := httptest.NewRequest(http.MethodGet, "http://example.com", nil) + w := httptest.NewRecorder() + + h.ServeHTTP(w, req) + + if w.Code != tt.expectedStatus { + t.Errorf("expected status %d, got %d", tt.expectedStatus, w.Code) + } + + gotHeader := w.Header().Get("X-Error-Message") + if gotHeader != tt.expectedHeader { + t.Errorf("expected header X-Error-Message %q, got %q", tt.expectedHeader, gotHeader) + } + }) + } +} diff --git a/pkg/router/handlers/execution_handler_test.go b/pkg/router/handlers/execution_handler_test.go index 222f818..017ab6b 100644 --- a/pkg/router/handlers/execution_handler_test.go +++ b/pkg/router/handlers/execution_handler_test.go @@ -70,7 +70,7 @@ func TestExecutionHandler_HappyPath(t *testing.T) { handler := handlers.ExecutionHandler(mockRunner, nil) // We need to use ErrorSink to get the 200 status code and handle errors - h := httpx.ToHandler(httpx.ErrorSink(nil), handler) + h := httpx.ToHandler(httpx.ErrorSink(nil, true), handler) req := httptest.NewRequest(http.MethodPost, "/exec?name=test-name", strings.NewReader("test-body")) req.Header.Set("X-Test", "test-header") @@ -130,7 +130,7 @@ func TestExecutionHandler_EmptyBody(t *testing.T) { mockCmd.EXPECT().Pid().Return(123).AnyTimes() handler := handlers.ExecutionHandler(mockRunner, nil) - h := httpx.ToHandler(httpx.ErrorSink(nil), handler) + h := httpx.ToHandler(httpx.ErrorSink(nil, true), handler) // Body is empty req := httptest.NewRequest(http.MethodPost, "/exec", nil) @@ -159,7 +159,7 @@ func TestExecutionHandler_NoCommandInContext(t *testing.T) { handler := handlers.ExecutionHandler(mockRunner, nil) // Using ErrorSink to translate WebError to HTTP status code - h := httpx.ToHandler(httpx.ErrorSink(nil), handler) + h := httpx.ToHandler(httpx.ErrorSink(nil, true), handler) // No URLCommand in context req := httptest.NewRequest(http.MethodGet, "/exec", nil) @@ -206,7 +206,7 @@ func TestExecutionHandler_ExtractParams_Query(t *testing.T) { mockCmd.EXPECT().Pid().Return(123).AnyTimes() handler := handlers.ExecutionHandler(mockRunner, nil) - h := httpx.ToHandler(httpx.ErrorSink(nil), handler) + h := httpx.ToHandler(httpx.ErrorSink(nil, true), handler) // URL with multiple values for parameter 'a' req := httptest.NewRequest(http.MethodGet, "/exec?a=1&a=2", nil) @@ -253,7 +253,7 @@ func TestExecutionHandler_ExtractParams_Headers(t *testing.T) { mockCmd.EXPECT().Pid().Return(123).AnyTimes() handler := handlers.ExecutionHandler(mockRunner, nil) - h := httpx.ToHandler(httpx.ErrorSink(nil), handler) + h := httpx.ToHandler(httpx.ErrorSink(nil, true), handler) req := httptest.NewRequest(http.MethodGet, "/exec", nil) req.Header.Add("X-Test-Header", "a") @@ -293,7 +293,7 @@ func TestExecutionHandler_ExtractParams_BodyReadError(t *testing.T) { } handler := handlers.ExecutionHandler(mockRunner, nil) - h := httpx.ToHandler(httpx.ErrorSink(nil), handler) + h := httpx.ToHandler(httpx.ErrorSink(nil, true), handler) req := httptest.NewRequest(http.MethodPost, "/exec", &errorReader{}) ctx := context.WithValue(req.Context(), handlers.URLCommandKey, cmdCfg) @@ -342,7 +342,7 @@ func TestExecutionHandler_BodyAsJSON_Disabled(t *testing.T) { mockCmd.EXPECT().Pid().Return(123).AnyTimes() handler := handlers.ExecutionHandler(mockRunner, nil) - h := httpx.ToHandler(httpx.ErrorSink(nil), handler) + h := httpx.ToHandler(httpx.ErrorSink(nil, true), handler) req := httptest.NewRequest(http.MethodPost, "/exec", strings.NewReader(`{"a": 1}`)) ctx := context.WithValue(req.Context(), handlers.URLCommandKey, cmdCfg) @@ -391,7 +391,7 @@ func TestExecutionHandler_BodyAsJSON_Valid(t *testing.T) { mockCmd.EXPECT().Pid().Return(123).AnyTimes() handler := handlers.ExecutionHandler(mockRunner, nil) - h := httpx.ToHandler(httpx.ErrorSink(nil), handler) + h := httpx.ToHandler(httpx.ErrorSink(nil, true), handler) req := httptest.NewRequest(http.MethodPost, "/exec", strings.NewReader(`{"a": 1}`)) ctx := context.WithValue(req.Context(), handlers.URLCommandKey, cmdCfg) @@ -425,7 +425,7 @@ func TestExecutionHandler_BodyAsJSON_Invalid(t *testing.T) { } handler := handlers.ExecutionHandler(mockRunner, nil) - h := httpx.ToHandler(httpx.ErrorSink(nil), handler) + h := httpx.ToHandler(httpx.ErrorSink(nil, true), handler) // Invalid JSON body req := httptest.NewRequest(http.MethodPost, "/exec", strings.NewReader(`{invalid json}`)) @@ -473,7 +473,7 @@ func TestExecutionHandler_BodyAsJSON_NonObject(t *testing.T) { t.Parallel() handler := handlers.ExecutionHandler(mockRunner, nil) - h := httpx.ToHandler(httpx.ErrorSink(nil), handler) + h := httpx.ToHandler(httpx.ErrorSink(nil, true), handler) req := httptest.NewRequest(http.MethodPost, "/exec", strings.NewReader(tc.body)) ctx := context.WithValue(req.Context(), handlers.URLCommandKey, cmdCfg) @@ -519,7 +519,7 @@ func TestExecutionHandler_BuildCommand_Success(t *testing.T) { mockCmd.EXPECT().Pid().Return(123).AnyTimes() handler := handlers.ExecutionHandler(mockRunner, nil) - h := httpx.ToHandler(httpx.ErrorSink(nil), handler) + h := httpx.ToHandler(httpx.ErrorSink(nil, true), handler) req := httptest.NewRequest(http.MethodGet, "/exec?name=test", nil) ctx := context.WithValue(req.Context(), handlers.URLCommandKey, cmdCfg) @@ -564,7 +564,7 @@ func TestExecutionHandler_BuildCommand_Error(t *testing.T) { } handler := handlers.ExecutionHandler(mockRunner, nil) - h := httpx.ToHandler(httpx.ErrorSink(nil), handler) + h := httpx.ToHandler(httpx.ErrorSink(nil, true), handler) req := httptest.NewRequest(http.MethodGet, tc.url, nil) ctx := context.WithValue(req.Context(), handlers.URLCommandKey, cmdCfg) @@ -621,7 +621,7 @@ func TestExecutionHandler_PrepareOutput_Text(t *testing.T) { mockCmd.EXPECT().Pid().Return(123).AnyTimes() handler := handlers.ExecutionHandler(mockRunner, nil) - h := httpx.ToHandler(httpx.ErrorSink(nil), handler) + h := httpx.ToHandler(httpx.ErrorSink(nil, true), handler) req := httptest.NewRequest(http.MethodGet, "/exec", nil) ctx := context.WithValue(req.Context(), handlers.URLCommandKey, cmdCfg) @@ -680,7 +680,7 @@ func TestExecutionHandler_PrepareOutput_Stream_Success(t *testing.T) { mockCmd.EXPECT().Pid().Return(123).AnyTimes() handler := handlers.ExecutionHandler(mockRunner, nil) - h := httpx.ToHandler(httpx.ErrorSink(nil), handler) + h := httpx.ToHandler(httpx.ErrorSink(nil, true), handler) req := httptest.NewRequest(http.MethodGet, "/exec", nil) ctx := context.WithValue(req.Context(), handlers.URLCommandKey, cmdCfg) @@ -734,7 +734,7 @@ func TestExecutionHandler_PrepareOutput_Stream_Failure(t *testing.T) { } handler := handlers.ExecutionHandler(mockRunner, nil) - h := httpx.ToHandler(httpx.ErrorSink(nil), handler) + h := httpx.ToHandler(httpx.ErrorSink(nil, true), handler) req := httptest.NewRequest(http.MethodGet, "/exec", nil) ctx := context.WithValue(req.Context(), handlers.URLCommandKey, cmdCfg) @@ -782,7 +782,7 @@ func TestExecutionHandler_PrepareOutput_None(t *testing.T) { mockCmd.EXPECT().Pid().Return(123).AnyTimes() handler := handlers.ExecutionHandler(mockRunner, nil) - h := httpx.ToHandler(httpx.ErrorSink(nil), handler) + h := httpx.ToHandler(httpx.ErrorSink(nil, true), handler) req := httptest.NewRequest(http.MethodGet, "/exec", nil) ctx := context.WithValue(req.Context(), handlers.URLCommandKey, cmdCfg) @@ -825,7 +825,7 @@ func TestExecutionHandler_CallGate(t *testing.T) { registry := callgate.NewRegistry(callgate.WithDefaults()) handler := handlers.ExecutionHandler(mockRunner, registry) - h := httpx.ToHandler(httpx.ErrorSink(nil), handler) + h := httpx.ToHandler(httpx.ErrorSink(nil, true), handler) urlCmd := &config.URLCommand{ URL: "GET /exec", @@ -860,7 +860,7 @@ func TestExecutionHandler_UnknownCallGateMode(t *testing.T) { registry := callgate.NewRegistry(callgate.WithDefaults()) handler := handlers.ExecutionHandler(mockRunner, registry) - h := httpx.ToHandler(httpx.ErrorSink(nil), handler) + h := httpx.ToHandler(httpx.ErrorSink(nil, true), handler) urlCmd := &config.URLCommand{ URL: "GET /exec", @@ -907,7 +907,7 @@ func TestExecutionHandler_PrepareOutput_Unknown(t *testing.T) { } handler := handlers.ExecutionHandler(mockRunner, nil) - h := httpx.ToHandler(httpx.ErrorSink(nil), handler) + h := httpx.ToHandler(httpx.ErrorSink(nil, true), handler) req := httptest.NewRequest(http.MethodGet, "/exec", nil) ctx := context.WithValue(req.Context(), handlers.URLCommandKey, cmdCfg) @@ -950,7 +950,7 @@ func TestExecutionHandler_ExecuteCommand_StartError_WritesFailedToStart(t *testi mockCmd.EXPECT().Wait().Times(0) handler := handlers.ExecutionHandler(mockRunner, nil) - h := httpx.ToHandler(httpx.ErrorSink(nil), handler) + h := httpx.ToHandler(httpx.ErrorSink(nil, true), handler) req := httptest.NewRequest(http.MethodGet, "/exec", nil) req = req.WithContext(context.WithValue(req.Context(), handlers.URLCommandKey, cmdCfg)) @@ -1013,7 +1013,7 @@ func TestExecutionHandler_ExecuteCommand_StdoutAndStderr_WriteToSameResponseWrit mockCmd.EXPECT().Pid().Return(123).AnyTimes() handler := handlers.ExecutionHandler(mockRunner, nil) - h := httpx.ToHandler(httpx.ErrorSink(nil), handler) + h := httpx.ToHandler(httpx.ErrorSink(nil, true), handler) req := httptest.NewRequest(http.MethodGet, "/exec", nil) req = req.WithContext(context.WithValue(req.Context(), handlers.URLCommandKey, cmdCfg)) @@ -1084,7 +1084,7 @@ func TestExecutionHandler_ExecuteCommand_SetsSetpgidTrue(t *testing.T) { mockCmd.EXPECT().Pid().Return(123).AnyTimes() handler := handlers.ExecutionHandler(mockRunner, nil) - h := httpx.ToHandler(httpx.ErrorSink(nil), handler) + h := httpx.ToHandler(httpx.ErrorSink(nil, true), handler) req := httptest.NewRequest(http.MethodGet, "/exec", nil) req = req.WithContext(context.WithValue(req.Context(), handlers.URLCommandKey, cmdCfg)) @@ -1133,7 +1133,7 @@ func TestExecutionHandler_SyncWait_ExitError_NonZeroExit_WritesFailureMessage(t mockCmd.EXPECT().ProcessState().Return(nil).AnyTimes() handler := handlers.ExecutionHandler(mockRunner, nil) - h := httpx.ToHandler(httpx.ErrorSink(nil), handler) + h := httpx.ToHandler(httpx.ErrorSink(nil, true), handler) req := httptest.NewRequest(http.MethodGet, "/exec", nil) req = req.WithContext(context.WithValue(req.Context(), handlers.URLCommandKey, cmdCfg)) @@ -1180,7 +1180,7 @@ func TestExecutionHandler_SyncWait_WaitReturnsNonExitError_WritesFailureMessage( mockCmd.EXPECT().ProcessState().Return(nil).AnyTimes() handler := handlers.ExecutionHandler(mockRunner, nil) - h := httpx.ToHandler(httpx.ErrorSink(nil), handler) + h := httpx.ToHandler(httpx.ErrorSink(nil, true), handler) req := httptest.NewRequest(http.MethodGet, "/exec", nil) req = req.WithContext(context.WithValue(req.Context(), handlers.URLCommandKey, cmdCfg)) @@ -1231,7 +1231,7 @@ func TestExecutionHandler_SyncWait_NoError_ProcessStateNil_DoesNotWriteFailureMe mockCmd.EXPECT().ProcessState().Return(nil).AnyTimes() handler := handlers.ExecutionHandler(mockRunner, nil) - h := httpx.ToHandler(httpx.ErrorSink(nil), handler) + h := httpx.ToHandler(httpx.ErrorSink(nil, true), handler) req := httptest.NewRequest(http.MethodGet, "/exec", nil) req = req.WithContext(context.WithValue(req.Context(), handlers.URLCommandKey, cmdCfg)) @@ -1295,7 +1295,7 @@ func TestExecutionHandler_TerminateOnCancel_NoGrace_SendsSIGKILL(t *testing.T) { }) handler := handlers.ExecutionHandler(mockRunner, nil) - h := httpx.ToHandler(httpx.ErrorSink(nil), handler) + h := httpx.ToHandler(httpx.ErrorSink(nil, true), handler) req := httptest.NewRequest(http.MethodGet, "/exec", nil) ctx, cancel := context.WithCancel(req.Context()) @@ -1365,7 +1365,7 @@ func TestExecutionHandler_TerminateOnCancel_WithGrace_Timeout_SendsSIGTERMThenSI ) handler := handlers.ExecutionHandler(mockRunner, nil) - h := httpx.ToHandler(httpx.ErrorSink(nil), handler) + h := httpx.ToHandler(httpx.ErrorSink(nil, true), handler) req := httptest.NewRequest(http.MethodGet, "/exec", nil) ctx, cancel := context.WithCancel(req.Context()) @@ -1423,7 +1423,7 @@ func TestExecutionHandler_TerminateOnCancel_WithGrace_ProcessEndsBeforeTimer_Sen mockRunner.EXPECT().Kill(-123, syscall.SIGKILL).Times(0) handler := handlers.ExecutionHandler(mockRunner, nil) - h := httpx.ToHandler(httpx.ErrorSink(nil), handler) + h := httpx.ToHandler(httpx.ErrorSink(nil, true), handler) req := httptest.NewRequest(http.MethodGet, "/exec", nil) ctx, cancel := context.WithCancel(req.Context()) @@ -1480,7 +1480,7 @@ func TestExecutionHandler_DeadlineExceeded_PrioritizesCtxErrOverExitError(t *tes mockCmd.EXPECT().ProcessState().Return(nil).AnyTimes() handler := handlers.ExecutionHandler(mockRunner, nil) - h := httpx.ToHandler(httpx.ErrorSink(nil), handler) + h := httpx.ToHandler(httpx.ErrorSink(nil, true), handler) req := httptest.NewRequest(http.MethodGet, "/exec", nil) @@ -1550,7 +1550,7 @@ func TestExecutionHandler_AsyncNone_ReturnsBeforeWait(t *testing.T) { }) handler := handlers.ExecutionHandler(mockRunner, nil) - h := httpx.ToHandler(httpx.ErrorSink(nil), handler) + h := httpx.ToHandler(httpx.ErrorSink(nil, true), handler) req := httptest.NewRequest(http.MethodGet, "/exec", nil) req = req.WithContext(context.WithValue(req.Context(), handlers.URLCommandKey, cmdCfg)) @@ -1636,7 +1636,7 @@ func TestExecutionHandler_AsyncNone_WaitError_LogsButDoesNotAffectResponse(t *te t.Cleanup(func() { log.SetOutput(origOut) }) handler := handlers.ExecutionHandler(mockRunner, nil) - h := httpx.ToHandler(httpx.ErrorSink(nil), handler) + h := httpx.ToHandler(httpx.ErrorSink(nil, true), handler) req := httptest.NewRequest(http.MethodGet, "/exec", nil) req = req.WithContext(context.WithValue(req.Context(), handlers.URLCommandKey, cmdCfg)) @@ -1722,7 +1722,7 @@ func TestExecutionHandler_RunCommand_AppendsErrorMessageToBody_OnNonZeroExit(t * mockCmd.EXPECT().Pid().Return(123).AnyTimes() handler := handlers.ExecutionHandler(mockRunner, nil) - h := httpx.ToHandler(httpx.ErrorSink(nil), handler) + h := httpx.ToHandler(httpx.ErrorSink(nil, true), handler) req := httptest.NewRequest(http.MethodGet, "/exec", nil) req = req.WithContext(context.WithValue(req.Context(), handlers.URLCommandKey, cmdCfg)) @@ -1809,7 +1809,7 @@ func TestExecutionHandler_RunCommand_WriteErrorMessageWriteFails_LogsError(t *te t.Cleanup(func() { log.SetOutput(origOut) }) handler := handlers.ExecutionHandler(mockRunner, nil) - h := httpx.ToHandler(httpx.ErrorSink(nil), handler) + h := httpx.ToHandler(httpx.ErrorSink(nil, true), handler) req := httptest.NewRequest(http.MethodGet, "/exec", nil) req = req.WithContext(context.WithValue(req.Context(), handlers.URLCommandKey, cmdCfg)) @@ -1871,7 +1871,7 @@ func TestExecutionHandler_CallGate_ImplicitGroupName(t *testing.T) { registry := callgate.NewRegistry(callgate.WithDefaults()) handler := handlers.ExecutionHandler(mockRunner, registry) - h := httpx.ToHandler(httpx.ErrorSink(nil), handler) + h := httpx.ToHandler(httpx.ErrorSink(nil, true), handler) // URL 1 urlCmd1 := &config.URLCommand{ @@ -1951,7 +1951,7 @@ func TestExecutionHandler_CallGate_EmptyGroupName(t *testing.T) { registry := callgate.NewRegistry(callgate.WithDefaults()) handler := handlers.ExecutionHandler(mockRunner, registry) - h := httpx.ToHandler(httpx.ErrorSink(nil), handler) + h := httpx.ToHandler(httpx.ErrorSink(nil, true), handler) // URL 1 with empty groupName urlCmd1 := &config.URLCommand{ diff --git a/pkg/router/router.go b/pkg/router/router.go index 8325839..22b6f80 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -17,7 +17,7 @@ func New(configuration *config.Config) *http.ServeMux { registry := callgate.NewRegistry(callgate.WithDefaults()) mux := http.NewServeMux() mux.Handle("/", httpx.ToHandler( - httpx.ErrorSink(log.Default()), + httpx.ErrorSink(log.Default(), false), httpx.WithMiddleware( httpx.Chain( handlers.RequestIDMiddleware(), From 589b0d002506c11d0d94dbf341f094447ec98f90 Mon Sep 17 00:00:00 2001 From: dkarczmarski Date: Mon, 23 Feb 2026 20:37:12 +0100 Subject: [PATCH 2/6] feat(config): add WithErrorHeader option to server configuration --- pkg/config/config.go | 1 + pkg/config/config_test.go | 11 +++++++++++ pkg/router/router.go | 2 +- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 2fc56d5..3d27e9a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -26,6 +26,7 @@ type Config struct { type ServerConfig struct { Address string `yaml:"address"` ShutdownGracePeriod *time.Duration `yaml:"shutdownGracePeriod"` + WithErrorHeader bool `yaml:"withErrorHeader"` HTTPSConfig ServerHTTPSConfig `yaml:"https"` } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 7c23c75..12bfb87 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -207,6 +207,17 @@ func TestSetDefaults(t *testing.T) { t.Errorf("expected ShutdownGracePeriod 5s, got %v", *cfg.Server.ShutdownGracePeriod) } }) + + t.Run("WithErrorHeader default value", func(t *testing.T) { + t.Parallel() + + configPath := setupTestFile(t, "https-disabled.yaml") + cfg := mustLoadConfig(t, configPath) + + if cfg.Server.WithErrorHeader { + t.Error("expected WithErrorHeader to be false by default") + } + }) } func mustLoadConfig(t *testing.T, configPath string) *config.Config { diff --git a/pkg/router/router.go b/pkg/router/router.go index 22b6f80..3561ff4 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -17,7 +17,7 @@ func New(configuration *config.Config) *http.ServeMux { registry := callgate.NewRegistry(callgate.WithDefaults()) mux := http.NewServeMux() mux.Handle("/", httpx.ToHandler( - httpx.ErrorSink(log.Default(), false), + httpx.ErrorSink(log.Default(), configuration.Server.WithErrorHeader), httpx.WithMiddleware( httpx.Chain( handlers.RequestIDMiddleware(), From fac97d230a7726247041836e74db77d60e07d93c Mon Sep 17 00:00:00 2001 From: dkarczmarski Date: Tue, 24 Feb 2026 14:47:30 +0100 Subject: [PATCH 3/6] refactor(httpx): remove WebErrorNoStack --- pkg/httpx/httpx_utils.go | 10 ---------- pkg/router/handlers/execution_handler.go | 10 +++++----- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/pkg/httpx/httpx_utils.go b/pkg/httpx/httpx_utils.go index 7774284..c1f1a0f 100644 --- a/pkg/httpx/httpx_utils.go +++ b/pkg/httpx/httpx_utils.go @@ -25,16 +25,6 @@ func NewWebError(err error, status int, message string) *WebError { } } -// NewWebErrorNoStack creates a new WebError without stack trace in logs. -func NewWebErrorNoStack(err error, status int, message string) *WebError { - return &WebError{ - err: err, - httpStatus: status, - message: message, - withStackTrace: false, - } -} - func (e *WebError) Error() string { if e == nil { return "" diff --git a/pkg/router/handlers/execution_handler.go b/pkg/router/handlers/execution_handler.go index 9d1be38..ea047cd 100644 --- a/pkg/router/handlers/execution_handler.go +++ b/pkg/router/handlers/execution_handler.go @@ -103,7 +103,7 @@ func runCommand( gate, gateErr := registry.GetOrCreate(groupName, cmd.CallGate.Mode) if gateErr != nil { - return httpx.NewWebErrorNoStack( + return httpx.NewWebError( gateErr, http.StatusInternalServerError, fmt.Sprintf("callgate registry: %v", gateErr), ) } @@ -152,7 +152,7 @@ func extractParams(request *http.Request, cmd *config.URLCommand) (map[string]in bodyBytes, err := io.ReadAll(request.Body) if err != nil { - return nil, httpx.NewWebErrorNoStack( + return nil, httpx.NewWebError( fmt.Errorf("failed to read request body: %w", err), http.StatusInternalServerError, "", @@ -176,7 +176,7 @@ func buildCommand( ) (*cmdbuilder.Result, error) { cmdResult, err := cmdbuilder.BuildCommand(template, params) if err != nil { - return nil, httpx.NewWebErrorNoStack( + return nil, httpx.NewWebError( fmt.Errorf("error building command: %w", err), http.StatusInternalServerError, "", @@ -232,7 +232,7 @@ func prepareOutput(responseWriter http.ResponseWriter, outputType string) (io.Wr async = true case "stream": if _, ok := responseWriter.(http.Flusher); !ok { - return nil, false, httpx.NewWebErrorNoStack( + return nil, false, httpx.NewWebError( fmt.Errorf("streaming not supported: %w", ErrBadConfiguration), http.StatusInternalServerError, "", @@ -250,7 +250,7 @@ func prepareOutput(responseWriter http.ResponseWriter, outputType string) (io.Wr responseWriter.Header().Set("Content-Type", "text/plain; charset=utf-8") default: - return nil, false, httpx.NewWebErrorNoStack( + return nil, false, httpx.NewWebError( fmt.Errorf("%w: unknown output type %q", ErrBadConfiguration, outputType), http.StatusInternalServerError, "", From 1763f67dde890a9a17a3209fb1f833430a95b0c6 Mon Sep 17 00:00:00 2001 From: dkarczmarski Date: Tue, 24 Feb 2026 14:51:24 +0100 Subject: [PATCH 4/6] refactor(httpx): remove stacktrace from ErrorSink --- pkg/httpx/httpx_utils.go | 41 +++++++++++----------------------------- 1 file changed, 11 insertions(+), 30 deletions(-) diff --git a/pkg/httpx/httpx_utils.go b/pkg/httpx/httpx_utils.go index c1f1a0f..015608d 100644 --- a/pkg/httpx/httpx_utils.go +++ b/pkg/httpx/httpx_utils.go @@ -4,24 +4,21 @@ import ( "errors" "log" "net/http" - "runtime/debug" ) // WebError represents an HTTP error with an associated HTTP status code and an optional public message. type WebError struct { - err error - httpStatus int - message string - withStackTrace bool + err error + httpStatus int + message string } // NewWebError creates a new WebError. func NewWebError(err error, status int, message string) *WebError { return &WebError{ - err: err, - httpStatus: status, - message: message, - withStackTrace: true, + err: err, + httpStatus: status, + message: message, } } @@ -49,9 +46,6 @@ func (e *WebError) HTTPStatus() int { return e.httpStatus } // Message returns the optional public message. func (e *WebError) Message() string { return e.message } -// StackTrace returns whether the stack trace should be logged. -func (e *WebError) StackTrace() bool { return e.withStackTrace } - type statusCoder interface { error HTTPStatus() int @@ -67,10 +61,6 @@ var ( _ messageCarrier = (*WebError)(nil) ) -type stackTracer interface { - StackTrace() bool -} - // ErrorSink returns a terminal handler that logs errors and writes appropriate HTTP responses. // If logger is nil, log.Default() is used. // If withErrorHeader is true, the error message is added to the X-Error-Message HTTP header. @@ -86,7 +76,7 @@ func ErrorSink(logger *log.Logger, withErrorHeader bool) func(WebHandler) http.H return } - status, msg, withStackTrace := extractErrorInfo(err) + status, msg := extractErrorInfo(err) if withErrorHeader { headerMsg := msg @@ -97,10 +87,8 @@ func ErrorSink(logger *log.Logger, withErrorHeader bool) func(WebHandler) http.H responseWriter.Header().Set("X-Error-Message", headerMsg) } - if status >= http.StatusInternalServerError && withStackTrace { - logger.Printf("[ERROR] %s %s: %v\nStack Trace:\n%s", - request.Method, request.URL.Path, err, debug.Stack(), - ) + if status >= http.StatusInternalServerError { + logger.Printf("[ERROR] %s %s: %v", request.Method, request.URL.Path, err) } else { logger.Printf("[WARN] %s %s: %v", request.Method, request.URL.Path, err) } @@ -116,10 +104,9 @@ func ErrorSink(logger *log.Logger, withErrorHeader bool) func(WebHandler) http.H } } -func extractErrorInfo(err error) (int, string, bool) { +func extractErrorInfo(err error) (int, string) { status := http.StatusInternalServerError msg := "" - withStackTrace := true var sc statusCoder @@ -131,11 +118,5 @@ func extractErrorInfo(err error) (int, string, bool) { } } - var st stackTracer - - if errors.As(err, &st) { - withStackTrace = st.StackTrace() - } - - return status, msg, withStackTrace + return status, msg } From f8792488c22d377d0485466c62b8fe766fd193fb Mon Sep 17 00:00:00 2001 From: dkarczmarski Date: Tue, 24 Feb 2026 15:09:19 +0100 Subject: [PATCH 5/6] refactor(httpx): simplify web error interface --- pkg/httpx/httpx_utils.go | 11 ++-------- pkg/httpx/httpx_utils_test.go | 40 +++++++++++++++++++++++++++++++---- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/pkg/httpx/httpx_utils.go b/pkg/httpx/httpx_utils.go index 015608d..1b1c1b2 100644 --- a/pkg/httpx/httpx_utils.go +++ b/pkg/httpx/httpx_utils.go @@ -49,16 +49,12 @@ func (e *WebError) Message() string { return e.message } type statusCoder interface { error HTTPStatus() int -} - -type messageCarrier interface { Message() string } // Compile-time check. var ( - _ statusCoder = (*WebError)(nil) - _ messageCarrier = (*WebError)(nil) + _ statusCoder = (*WebError)(nil) ) // ErrorSink returns a terminal handler that logs errors and writes appropriate HTTP responses. @@ -112,10 +108,7 @@ func extractErrorInfo(err error) (int, string) { if errors.As(err, &sc) { status = sc.HTTPStatus() - - if mc, ok := sc.(messageCarrier); ok { - msg = mc.Message() - } + msg = sc.Message() } return status, msg diff --git a/pkg/httpx/httpx_utils_test.go b/pkg/httpx/httpx_utils_test.go index 6bd2431..13ae558 100644 --- a/pkg/httpx/httpx_utils_test.go +++ b/pkg/httpx/httpx_utils_test.go @@ -9,6 +9,16 @@ import ( "github.com/dkarczmarski/webcmd/pkg/httpx" ) +type customStatusError struct { + err error + status int + msg string +} + +func (e *customStatusError) Error() string { return e.err.Error() } +func (e *customStatusError) HTTPStatus() int { return e.status } +func (e *customStatusError) Message() string { return e.msg } + func TestErrorSink(t *testing.T) { t.Parallel() @@ -18,34 +28,51 @@ func TestErrorSink(t *testing.T) { withErrorHeader bool expectedHeader string expectedStatus int + expectedBody string }{ { - name: "No error header, normal error", + name: "No error header, normal error (NOT statusCoder)", err: errors.New("standard error"), withErrorHeader: false, expectedHeader: "", expectedStatus: http.StatusInternalServerError, + expectedBody: "", }, { - name: "With error header, normal error", + name: "With error header, normal error (NOT statusCoder)", err: errors.New("standard error"), withErrorHeader: true, expectedHeader: "standard error", expectedStatus: http.StatusInternalServerError, + expectedBody: "", }, { - name: "With error header, WebError with message", + name: "With error header, WebError with message (statusCoder)", err: httpx.NewWebError(errors.New("internal"), http.StatusBadRequest, "public message"), withErrorHeader: true, expectedHeader: "public message", expectedStatus: http.StatusBadRequest, + expectedBody: "public message\n", }, { - name: "With error header, WebError without message", + name: "With error header, WebError without message (statusCoder)", err: httpx.NewWebError(errors.New("internal error message"), http.StatusBadRequest, ""), withErrorHeader: true, expectedHeader: "internal error message", expectedStatus: http.StatusBadRequest, + expectedBody: "", + }, + { + name: "Custom statusCoder implementation", + err: &customStatusError{ + err: errors.New("custom error"), + status: http.StatusTeapot, + msg: "I am a teapot", + }, + withErrorHeader: false, + expectedHeader: "", + expectedStatus: http.StatusTeapot, + expectedBody: "I am a teapot\n", }, } @@ -73,6 +100,11 @@ func TestErrorSink(t *testing.T) { if gotHeader != tt.expectedHeader { t.Errorf("expected header X-Error-Message %q, got %q", tt.expectedHeader, gotHeader) } + + gotBody := w.Body.String() + if gotBody != tt.expectedBody { + t.Errorf("expected body %q, got %q", tt.expectedBody, gotBody) + } }) } } From 75d3a6a4acbce7c2abbb935b664b20476c9af9ee Mon Sep 17 00:00:00 2001 From: dkarczmarski Date: Wed, 25 Feb 2026 14:01:56 +0100 Subject: [PATCH 6/6] feat(httpx): add support for silent errors in ErrorSink --- pkg/httpx/httpx_utils.go | 32 ++++++++++++++++++++++++++++++++ pkg/httpx/httpx_utils_test.go | 8 ++++++++ 2 files changed, 40 insertions(+) diff --git a/pkg/httpx/httpx_utils.go b/pkg/httpx/httpx_utils.go index 1b1c1b2..25094b5 100644 --- a/pkg/httpx/httpx_utils.go +++ b/pkg/httpx/httpx_utils.go @@ -52,14 +52,41 @@ type statusCoder interface { Message() string } +type silentError interface { + error + Silent() bool +} + +type SilentError struct { + err error +} + +func NewSilentError(err error) *SilentError { + return &SilentError{err: err} +} + +func (e *SilentError) Error() string { + if e.err != nil { + return e.err.Error() + } + + return "silent error" +} + +func (e *SilentError) Unwrap() error { return e.err } + +func (e *SilentError) Silent() bool { return true } + // Compile-time check. var ( _ statusCoder = (*WebError)(nil) + _ silentError = (*SilentError)(nil) ) // ErrorSink returns a terminal handler that logs errors and writes appropriate HTTP responses. // If logger is nil, log.Default() is used. // If withErrorHeader is true, the error message is added to the X-Error-Message HTTP header. +// If the error implements silentError and Silent() returns true, it is completely ignored. func ErrorSink(logger *log.Logger, withErrorHeader bool) func(WebHandler) http.Handler { if logger == nil { logger = log.Default() @@ -72,6 +99,11 @@ func ErrorSink(logger *log.Logger, withErrorHeader bool) func(WebHandler) http.H return } + var se silentError + if errors.As(err, &se) && se.Silent() { + return + } + status, msg := extractErrorInfo(err) if withErrorHeader { diff --git a/pkg/httpx/httpx_utils_test.go b/pkg/httpx/httpx_utils_test.go index 13ae558..abc4115 100644 --- a/pkg/httpx/httpx_utils_test.go +++ b/pkg/httpx/httpx_utils_test.go @@ -74,6 +74,14 @@ func TestErrorSink(t *testing.T) { expectedStatus: http.StatusTeapot, expectedBody: "I am a teapot\n", }, + { + name: "SilentError should be ignored", + err: httpx.NewSilentError(errors.New("silent error")), + withErrorHeader: true, + expectedHeader: "", + expectedStatus: http.StatusOK, + expectedBody: "", + }, } for _, tt := range tests {