From 42d484f9a2ffdd295b705dc7ae9b8ad7dd7e1afc Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sat, 3 Oct 2026 15:03:30 +0800 Subject: [PATCH] fix(claude): map request timeout status to timeout_error - Map `http.StatusRequestTimeout` (408) to Claude `timeout_error` response type Closes: #6298 --- sdk/api/handlers/claude/code_handlers.go | 2 +- .../claude/code_handlers_error_test.go | 79 +++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/sdk/api/handlers/claude/code_handlers.go b/sdk/api/handlers/claude/code_handlers.go index ab380078d..9a576e886 100644 --- a/sdk/api/handlers/claude/code_handlers.go +++ b/sdk/api/handlers/claude/code_handlers.go @@ -459,7 +459,7 @@ func claudeErrorTypeFromStatus(status int) string { return "request_too_large" case http.StatusTooManyRequests: return "rate_limit_error" - case http.StatusGatewayTimeout: + case http.StatusRequestTimeout, http.StatusGatewayTimeout: return "timeout_error" case 529: return "overloaded_error" diff --git a/sdk/api/handlers/claude/code_handlers_error_test.go b/sdk/api/handlers/claude/code_handlers_error_test.go index 6fca0c0c2..fbed86859 100644 --- a/sdk/api/handlers/claude/code_handlers_error_test.go +++ b/sdk/api/handlers/claude/code_handlers_error_test.go @@ -4,6 +4,7 @@ import ( "errors" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -14,6 +15,84 @@ import ( "github.com/tidwall/gjson" ) +func TestClaudeErrorTypeFromStatus(t *testing.T) { + for _, tt := range []struct { + status int + want string + }{ + {http.StatusBadRequest, "invalid_request_error"}, + {http.StatusRequestTimeout, "timeout_error"}, + {http.StatusTooManyRequests, "rate_limit_error"}, + {http.StatusInternalServerError, "api_error"}, + {http.StatusGatewayTimeout, "timeout_error"}, + } { + t.Run(http.StatusText(tt.status), func(t *testing.T) { + if got := claudeErrorTypeFromStatus(tt.status); got != tt.want { + t.Fatalf("error.type = %q, want %q", got, tt.want) + } + }) + } +} + +func TestClaudeIncompleteStreamError(t *testing.T) { + const message = "stream error: stream disconnected before completion: stream closed before response.completed" + for _, streaming := range []bool{false, true} { + name := "JSON" + if streaming { + name = "committed SSE" + } + t.Run(name, func(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + handler := NewClaudeCodeAPIHandler(&handlers.BaseAPIHandler{}) + msg := &interfaces.ErrorMessage{ + StatusCode: http.StatusRequestTimeout, + Error: errors.New(message), + } + wantStatus := http.StatusRequestTimeout + if streaming { + c.Header("Content-Type", "text/event-stream") + _, _ = c.Writer.Write([]byte("event: message_start\ndata: {}\n\n")) + c.Writer.Flush() + errs := make(chan *interfaces.ErrorMessage, 1) + errs <- msg + close(errs) + handler.forwardClaudeStream(c, c.Writer, func(err error) { + if err != msg.Error { + t.Errorf("cancel error = %v, want %v", err, msg.Error) + } + }, nil, errs) + wantStatus = http.StatusOK + } else { + handler.WriteErrorResponse(c, msg) + } + if recorder.Code != wantStatus { + t.Fatalf("status = %d, want %d", recorder.Code, wantStatus) + } + body := recorder.Body.String() + if streaming { + const prefix = "event: error\ndata: " + if strings.Count(body, prefix) != 1 { + t.Fatalf("expected one terminal error event; body=%s", body) + } + _, body, _ = strings.Cut(body, prefix) + body = strings.TrimSpace(body) + } + if got := gjson.Get(body, "type").String(); got != "error" { + t.Errorf("type = %q, want error", got) + } + if got := gjson.Get(body, "error.type").String(); got != "timeout_error" { + t.Errorf("error.type = %q, want timeout_error", got) + } + if got := gjson.Get(body, "error.message").String(); got != message { + t.Errorf("error.message = %q, want %q", got, message) + } + }) + } +} + func TestClaudeErrorExtractsOpenAIStyleUpstreamJSON(t *testing.T) { handler := &ClaudeCodeAPIHandler{} msg := &interfaces.ErrorMessage{