From 6ac9b31e4eeb743b89b9fbccee1c4fe2e2c5b43a Mon Sep 17 00:00:00 2001 From: edlsh Date: Sat, 28 Feb 2026 22:43:46 -0500 Subject: [PATCH] Handle compressed error decode failures safely --- internal/runtime/executor/claude_executor.go | 59 +++++++++++++---- .../runtime/executor/claude_executor_test.go | 64 +++++++++++++++++++ 2 files changed, 110 insertions(+), 13 deletions(-) diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index ddbe9297..483a4830 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -185,14 +185,25 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r } recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone()) if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { - // Decompress error responses (e.g. gzip-compressed 400 errors from Anthropic API) + // Decompress error responses (e.g. gzip-compressed 400 errors from Anthropic API). errBody := httpResp.Body if ce := httpResp.Header.Get("Content-Encoding"); ce != "" { - if decoded, decErr := decodeResponseBody(httpResp.Body, ce); decErr == nil { - errBody = decoded + var decErr error + errBody, decErr = decodeResponseBody(httpResp.Body, ce) + if decErr != nil { + recordAPIResponseError(ctx, e.cfg, decErr) + msg := fmt.Sprintf("failed to decode error response body (encoding=%s): %v", ce, decErr) + logWithRequestID(ctx).Warn(msg) + return resp, statusErr{code: httpResp.StatusCode, msg: msg} } } - b, _ := io.ReadAll(errBody) + b, readErr := io.ReadAll(errBody) + if readErr != nil { + recordAPIResponseError(ctx, e.cfg, readErr) + msg := fmt.Sprintf("failed to read error response body: %v", readErr) + logWithRequestID(ctx).Warn(msg) + b = []byte(msg) + } appendAPIResponseChunk(ctx, e.cfg, b) logWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), b)) err = statusErr{code: httpResp.StatusCode, msg: string(b)} @@ -339,14 +350,25 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A } recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone()) if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { - // Decompress error responses (e.g. gzip-compressed 400 errors from Anthropic API) + // Decompress error responses (e.g. gzip-compressed 400 errors from Anthropic API). errBody := httpResp.Body if ce := httpResp.Header.Get("Content-Encoding"); ce != "" { - if decoded, decErr := decodeResponseBody(httpResp.Body, ce); decErr == nil { - errBody = decoded + var decErr error + errBody, decErr = decodeResponseBody(httpResp.Body, ce) + if decErr != nil { + recordAPIResponseError(ctx, e.cfg, decErr) + msg := fmt.Sprintf("failed to decode error response body (encoding=%s): %v", ce, decErr) + logWithRequestID(ctx).Warn(msg) + return nil, statusErr{code: httpResp.StatusCode, msg: msg} } } - b, _ := io.ReadAll(errBody) + b, readErr := io.ReadAll(errBody) + if readErr != nil { + recordAPIResponseError(ctx, e.cfg, readErr) + msg := fmt.Sprintf("failed to read error response body: %v", readErr) + logWithRequestID(ctx).Warn(msg) + b = []byte(msg) + } appendAPIResponseChunk(ctx, e.cfg, b) logWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), b)) if errClose := errBody.Close(); errClose != nil { @@ -497,14 +519,25 @@ func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut } recordAPIResponseMetadata(ctx, e.cfg, resp.StatusCode, resp.Header.Clone()) if resp.StatusCode < 200 || resp.StatusCode >= 300 { - // Decompress error responses (e.g. gzip-compressed 400 errors from Anthropic API) - errBody := io.ReadCloser(resp.Body) + // Decompress error responses (e.g. gzip-compressed 400 errors from Anthropic API). + errBody := resp.Body if ce := resp.Header.Get("Content-Encoding"); ce != "" { - if decoded, decErr := decodeResponseBody(resp.Body, ce); decErr == nil { - errBody = decoded + var decErr error + errBody, decErr = decodeResponseBody(resp.Body, ce) + if decErr != nil { + recordAPIResponseError(ctx, e.cfg, decErr) + msg := fmt.Sprintf("failed to decode error response body (encoding=%s): %v", ce, decErr) + logWithRequestID(ctx).Warn(msg) + return cliproxyexecutor.Response{}, statusErr{code: resp.StatusCode, msg: msg} } } - b, _ := io.ReadAll(errBody) + b, readErr := io.ReadAll(errBody) + if readErr != nil { + recordAPIResponseError(ctx, e.cfg, readErr) + msg := fmt.Sprintf("failed to read error response body: %v", readErr) + logWithRequestID(ctx).Warn(msg) + b = []byte(msg) + } appendAPIResponseChunk(ctx, e.cfg, b) if errClose := errBody.Close(); errClose != nil { log.Errorf("response body close error: %v", errClose) diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index d90076b6..f9553f9a 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/http/httptest" + "strings" "testing" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" @@ -519,3 +520,66 @@ func hasTTLOrderingViolation(payload []byte) bool { return violates } + +func TestClaudeExecutor_Execute_InvalidGzipErrorBodyReturnsDecodeMessage(t *testing.T) { + testClaudeExecutorInvalidCompressedErrorBody(t, func(executor *ClaudeExecutor, auth *cliproxyauth.Auth, payload []byte) error { + _, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ + Model: "claude-3-5-sonnet-20241022", + Payload: payload, + }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("claude")}) + return err + }) +} + +func TestClaudeExecutor_ExecuteStream_InvalidGzipErrorBodyReturnsDecodeMessage(t *testing.T) { + testClaudeExecutorInvalidCompressedErrorBody(t, func(executor *ClaudeExecutor, auth *cliproxyauth.Auth, payload []byte) error { + _, err := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{ + Model: "claude-3-5-sonnet-20241022", + Payload: payload, + }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("claude")}) + return err + }) +} + +func TestClaudeExecutor_CountTokens_InvalidGzipErrorBodyReturnsDecodeMessage(t *testing.T) { + testClaudeExecutorInvalidCompressedErrorBody(t, func(executor *ClaudeExecutor, auth *cliproxyauth.Auth, payload []byte) error { + _, err := executor.CountTokens(context.Background(), auth, cliproxyexecutor.Request{ + Model: "claude-3-5-sonnet-20241022", + Payload: payload, + }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("claude")}) + return err + }) +} + +func testClaudeExecutorInvalidCompressedErrorBody( + t *testing.T, + invoke func(executor *ClaudeExecutor, auth *cliproxyauth.Auth, payload []byte) error, +) { + t.Helper() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Encoding", "gzip") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte("not-a-valid-gzip-stream")) + })) + defer server.Close() + + executor := NewClaudeExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{Attributes: map[string]string{ + "api_key": "key-123", + "base_url": server.URL, + }} + payload := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`) + + err := invoke(executor, auth, payload) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "failed to decode error response body") { + t.Fatalf("expected decode failure message, got: %v", err) + } + if statusProvider, ok := err.(interface{ StatusCode() int }); !ok || statusProvider.StatusCode() != http.StatusBadRequest { + t.Fatalf("expected status code 400, got: %v", err) + } +}