diff --git a/internal/api/server_routes.go b/internal/api/server_routes.go index 3003e2ce1..e008e01f5 100644 --- a/internal/api/server_routes.go +++ b/internal/api/server_routes.go @@ -18,6 +18,7 @@ import ( codexlive "github.com/router-for-me/CLIProxyAPI/v7/internal/client/codex/live" codexmodels "github.com/router-for-me/CLIProxyAPI/v7/internal/client/codex/models" "github.com/router-for-me/CLIProxyAPI/v7/internal/client/grokbuild" + "github.com/router-for-me/CLIProxyAPI/v7/internal/clienterror" "github.com/router-for-me/CLIProxyAPI/v7/internal/home" "github.com/router-for-me/CLIProxyAPI/v7/internal/logging" "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" @@ -269,7 +270,7 @@ func (s *Server) codexAlphaSearch(c *gin.Context) { body, err := io.ReadAll(io.LimitReader(c.Request.Body, 16<<20)) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read search request"}) + c.JSON(clienterror.HTTPStatusFromErrorOr(err, http.StatusBadRequest), gin.H{"error": "Failed to read search request"}) return } @@ -288,7 +289,7 @@ func (s *Server) codexAlphaSearch(c *gin.Context) { selectionModel, errRoute := s.codexAlphaSearchSelectionModel(ctx, c, body, strings.TrimSpace(routing.Model)) if errRoute != nil { log.WithError(errRoute).Warn("codex alpha search: model router returned an unsupported target") - c.JSON(http.StatusServiceUnavailable, gin.H{"error": errRoute.Error()}) + c.JSON(clienterror.HTTPStatusFromErrorOr(errRoute, http.StatusServiceUnavailable), gin.H{"error": errRoute.Error()}) return } selectionOpts := coreexecutor.Options{Headers: selectionHeaders, OriginalRequest: body} @@ -303,10 +304,7 @@ func (s *Server) codexAlphaSearch(c *gin.Context) { selected, err = s.handlers.AuthManager.SelectAuthWithCredentialPolicy(ctx, "codex", selectionModel, auth.CredentialPolicyCodexAlphaSearchV1, selectionOpts) } if err != nil { - status := http.StatusServiceUnavailable - if statusError, ok := err.(interface{ StatusCode() int }); ok && statusError.StatusCode() > 0 { - status = statusError.StatusCode() - } + status := clienterror.HTTPStatusFromErrorOr(err, http.StatusServiceUnavailable) for _, value := range auth.SafeResponseHeaders(err).Values("Retry-After") { c.Writer.Header().Add("Retry-After", value) } @@ -384,7 +382,7 @@ func (s *Server) codexAlphaSearch(c *gin.Context) { if selection != nil { selection.End("attempt_canceled") } - c.JSON(http.StatusRequestTimeout, gin.H{"error": errCtx.Error()}) + c.JSON(clienterror.HTTPStatusFromErrorOr(errCtx, http.StatusRequestTimeout), gin.H{"error": errCtx.Error()}) return } resp, err := performRequest(selected) @@ -400,7 +398,7 @@ func (s *Server) codexAlphaSearch(c *gin.Context) { selection.End("request_failed") } helps.RecordAPIResponseError(ctx, s.cfg, err) - c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + c.JSON(clienterror.HTTPStatusFromErrorOr(err, http.StatusBadGateway), gin.H{"error": err.Error()}) return } if selection != nil && resp.StatusCode == http.StatusUnauthorized { @@ -413,11 +411,7 @@ func (s *Server) codexAlphaSearch(c *gin.Context) { refreshed, didRefresh, errRefresh := s.handlers.AuthManager.RefreshHomeSelectionAfterUnauthorized(ctx, selection, selected) if errRefresh != nil { selection.End("refresh_failed") - status := http.StatusServiceUnavailable - if statusError, ok := errRefresh.(interface{ StatusCode() int }); ok && statusError.StatusCode() > 0 { - status = statusError.StatusCode() - } - c.JSON(status, gin.H{"error": errRefresh.Error()}) + c.JSON(clienterror.HTTPStatusFromErrorOr(errRefresh, http.StatusServiceUnavailable), gin.H{"error": errRefresh.Error()}) return } if !didRefresh || refreshed == nil { @@ -436,7 +430,7 @@ func (s *Server) codexAlphaSearch(c *gin.Context) { } selection.End("retry_failed") helps.RecordAPIResponseError(ctx, s.cfg, err) - c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + c.JSON(clienterror.HTTPStatusFromErrorOr(err, http.StatusBadGateway), gin.H{"error": err.Error()}) return } if resp.StatusCode == http.StatusUnauthorized { @@ -464,7 +458,7 @@ func (s *Server) codexAlphaSearch(c *gin.Context) { upstreamBody, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20)) if err != nil { helps.RecordAPIResponseError(ctx, s.cfg, err) - c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to read Codex search response"}) + c.JSON(clienterror.HTTPStatusFromErrorOr(err, http.StatusBadGateway), gin.H{"error": "Failed to read Codex search response"}) return } helps.AppendAPIResponseChunk(ctx, s.cfg, upstreamBody) diff --git a/internal/client/codex/live/live.go b/internal/client/codex/live/live.go index 9e8b155d3..ac36682ea 100644 --- a/internal/client/codex/live/live.go +++ b/internal/client/codex/live/live.go @@ -17,6 +17,7 @@ import ( "sync" "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v7/internal/clienterror" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" "github.com/router-for-me/CLIProxyAPI/v7/internal/logging" "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" @@ -175,7 +176,7 @@ func (h *Handler) Handle(c *gin.Context) { body, errRead := readBody(c.Request.Body) if errRead != nil { - status := http.StatusBadRequest + status := clienterror.HTTPStatusFromErrorOr(errRead, http.StatusBadRequest) if errors.Is(errRead, errBodyTooLarge) { status = http.StatusRequestEntityTooLarge } @@ -246,7 +247,7 @@ func (h *Handler) Handle(c *gin.Context) { authIndex: selectedIndex, }) if errSDP != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": errSDP.Error()}) + c.JSON(clienterror.HTTPStatusFromErrorOr(errSDP, http.StatusBadGateway), gin.H{"error": errSDP.Error()}) return } defer func() { @@ -291,7 +292,7 @@ func (h *Handler) Handle(c *gin.Context) { if selection != nil { selection.End("attempt_canceled") } - c.JSON(http.StatusRequestTimeout, gin.H{"error": errContext.Error()}) + c.JSON(clienterror.HTTPStatusFromErrorOr(errContext, http.StatusRequestTimeout), gin.H{"error": errContext.Error()}) return } resp, errRequest := performRequest(selected) @@ -300,7 +301,7 @@ func (h *Handler) Handle(c *gin.Context) { selection.End("request_failed") } helps.RecordAPIResponseError(ctx, runtimeConfig, errRequest) - c.JSON(http.StatusBadGateway, gin.H{"error": errRequest.Error()}) + c.JSON(clienterror.HTTPStatusFromErrorOr(errRequest, http.StatusBadGateway), gin.H{"error": errRequest.Error()}) return } if selection != nil && resp.StatusCode == http.StatusUnauthorized { @@ -327,7 +328,7 @@ func (h *Handler) Handle(c *gin.Context) { if errRequest != nil { selection.End("retry_failed") helps.RecordAPIResponseError(ctx, runtimeConfig, errRequest) - c.JSON(http.StatusBadGateway, gin.H{"error": errRequest.Error()}) + c.JSON(clienterror.HTTPStatusFromErrorOr(errRequest, http.StatusBadGateway), gin.H{"error": errRequest.Error()}) return } if resp.StatusCode == http.StatusUnauthorized { @@ -361,10 +362,12 @@ func (h *Handler) Handle(c *gin.Context) { if errResponse != nil { helps.RecordAPIResponseError(ctx, runtimeConfig, errResponse) message := "Failed to read Codex live response" + status := clienterror.HTTPStatusFromErrorOr(errResponse, http.StatusBadGateway) if errors.Is(errResponse, errBodyTooLarge) { message = "Codex live response body too large" + status = http.StatusBadGateway } - c.JSON(http.StatusBadGateway, gin.H{"error": message}) + c.JSON(status, gin.H{"error": message}) return } helps.AppendAPIResponseChunk(ctx, runtimeConfig, responseBody) @@ -389,7 +392,7 @@ func (h *Handler) Handle(c *gin.Context) { } downstreamAnswer, errAnswer := mediaSession.AcceptUpstreamAnswer(ctx, upstreamAnswer) if errAnswer != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": errAnswer.Error()}) + c.JSON(clienterror.HTTPStatusFromErrorOr(errAnswer, http.StatusBadGateway), gin.H{"error": errAnswer.Error()}) return } responseBodyToWrite = []byte(downstreamAnswer) @@ -718,10 +721,7 @@ func writeResponseHeaders(destination, source http.Header) { } func writeSelectionError(c *gin.Context, err error) { - status := http.StatusServiceUnavailable - if statusError, ok := err.(interface{ StatusCode() int }); ok && statusError.StatusCode() > 0 { - status = statusError.StatusCode() - } + status := clienterror.HTTPStatusFromErrorOr(err, http.StatusServiceUnavailable) for _, value := range auth.SafeResponseHeaders(err).Values("Retry-After") { c.Writer.Header().Add("Retry-After", value) } diff --git a/internal/client/codex/live/sideband.go b/internal/client/codex/live/sideband.go index fe3b6155f..cdbbbd5ba 100644 --- a/internal/client/codex/live/sideband.go +++ b/internal/client/codex/live/sideband.go @@ -14,6 +14,7 @@ import ( "github.com/gin-gonic/gin" "github.com/gorilla/websocket" + "github.com/router-for-me/CLIProxyAPI/v7/internal/clienterror" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" "github.com/router-for-me/CLIProxyAPI/v7/internal/logging" "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" @@ -550,7 +551,7 @@ func callIDFromLocation(location string) string { } func handleSidebandDialError(c *gin.Context, ctx context.Context, cfg *config.Config, response *http.Response, errDial error) { - status := http.StatusBadGateway + status := clienterror.HTTPStatusFromErrorOr(errDial, http.StatusBadGateway) if response != nil { if response.StatusCode > 0 { status = response.StatusCode diff --git a/internal/clienterror/client_error.go b/internal/clienterror/client_error.go index ac575e9cb..3a2f532d8 100644 --- a/internal/clienterror/client_error.go +++ b/internal/clienterror/client_error.go @@ -2,6 +2,7 @@ package clienterror import ( + "context" "encoding/json" "errors" "net/http" @@ -10,6 +11,10 @@ import ( "github.com/tidwall/gjson" ) +// StatusClientClosedRequest is the nginx-style status used when the client +// aborts the request before the proxy finishes (context.Canceled). +const StatusClientClosedRequest = 499 + var requestFaultCodes = map[string]struct{}{ "cyber_policy": {}, "context_length_exceeded": {}, @@ -29,6 +34,40 @@ var requestFaultTypes = map[string]struct{}{ "invalid_prompt": {}, } +// HTTPStatusFromError extracts an HTTP status from err. +// Explicit StatusCode() values win. Otherwise context.Canceled maps to 499 +// and context.DeadlineExceeded maps to 504. Returns 0 when unknown. +func HTTPStatusFromError(err error) int { + if err == nil { + return 0 + } + type statusCoder interface { + StatusCode() int + } + var sc statusCoder + if errors.As(err, &sc) && sc != nil { + if code := sc.StatusCode(); code > 0 { + return code + } + } + if errors.Is(err, context.Canceled) { + return StatusClientClosedRequest + } + if errors.Is(err, context.DeadlineExceeded) { + return http.StatusGatewayTimeout + } + return 0 +} + +// HTTPStatusFromErrorOr is like HTTPStatusFromError but returns fallback when +// the error does not carry a known status. +func HTTPStatusFromErrorOr(err error, fallback int) int { + if code := HTTPStatusFromError(err); code > 0 { + return code + } + return fallback +} + // IsRequestFault reports whether an upstream failure is caused by the request // and therefore must not rotate or penalize credentials. func IsRequestFault(status int, err error) bool { diff --git a/internal/clienterror/client_error_test.go b/internal/clienterror/client_error_test.go index 2582b6f9a..db17df399 100644 --- a/internal/clienterror/client_error_test.go +++ b/internal/clienterror/client_error_test.go @@ -1,8 +1,11 @@ package clienterror import ( + "context" "errors" + "fmt" "net/http" + "net/url" "testing" ) @@ -14,6 +17,94 @@ type statusError struct { func (e statusError) Error() string { return e.body } func (e statusError) StatusCode() int { return e.status } +func TestHTTPStatusFromError(t *testing.T) { + tests := []struct { + name string + err error + want int + }{ + {name: "nil", err: nil, want: 0}, + {name: "plain error", err: errors.New("boom"), want: 0}, + {name: "context canceled", err: context.Canceled, want: StatusClientClosedRequest}, + {name: "context deadline exceeded", err: context.DeadlineExceeded, want: http.StatusGatewayTimeout}, + { + name: "url error wraps canceled", + err: &url.Error{Op: "Post", URL: "https://example.com", Err: context.Canceled}, + want: StatusClientClosedRequest, + }, + { + name: "url error wraps deadline", + err: &url.Error{Op: "Post", URL: "https://example.com", Err: context.DeadlineExceeded}, + want: http.StatusGatewayTimeout, + }, + { + name: "fmt wrap canceled", + err: fmt.Errorf("upstream: %w", context.Canceled), + want: StatusClientClosedRequest, + }, + { + name: "explicit status code wins", + err: statusError{status: http.StatusTooManyRequests, body: "rate limited"}, + want: http.StatusTooManyRequests, + }, + { + name: "explicit status wins over canceled unwrap", + err: statusAndUnwrapError{ + status: http.StatusTooManyRequests, + body: "rate limited", + cause: context.Canceled, + }, + want: http.StatusTooManyRequests, + }, + { + name: "zero status code falls through to canceled unwrap", + err: statusAndUnwrapError{ + status: 0, + body: "canceled", + cause: context.Canceled, + }, + want: StatusClientClosedRequest, + }, + { + name: "zero status code without unwrap stays unknown", + err: statusError{status: 0, body: context.Canceled.Error()}, + want: 0, + }, + { + name: "wrapped status code via errors.As", + err: fmt.Errorf("execute failed: %w", statusError{status: http.StatusUnauthorized, body: "unauthorized"}), + want: http.StatusUnauthorized, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := HTTPStatusFromError(tc.err); got != tc.want { + t.Fatalf("HTTPStatusFromError() = %d, want %d", got, tc.want) + } + }) + } + + if got := HTTPStatusFromErrorOr(errors.New("boom"), http.StatusBadGateway); got != http.StatusBadGateway { + t.Fatalf("HTTPStatusFromErrorOr(plain) = %d, want %d", got, http.StatusBadGateway) + } + if got := HTTPStatusFromErrorOr(context.Canceled, http.StatusBadGateway); got != StatusClientClosedRequest { + t.Fatalf("HTTPStatusFromErrorOr(canceled) = %d, want %d", got, StatusClientClosedRequest) + } +} + +type statusAndUnwrapError struct { + status int + body string + cause error +} + +func (e statusAndUnwrapError) Error() string { return e.body } +func (e statusAndUnwrapError) StatusCode() int { + return e.status +} +func (e statusAndUnwrapError) Unwrap() error { return e.cause } + func TestIsRequestFaultStructuredIdentifiers(t *testing.T) { for _, code := range []string{ "cyber_policy", diff --git a/internal/runtime/executor/helps/usage_helpers.go b/internal/runtime/executor/helps/usage_helpers.go index ec305c880..fa355bd69 100644 --- a/internal/runtime/executor/helps/usage_helpers.go +++ b/internal/runtime/executor/helps/usage_helpers.go @@ -3,7 +3,6 @@ package helps import ( "bytes" "context" - "errors" "fmt" "io" "net/http" @@ -13,6 +12,7 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v7/internal/clienterror" internallogging "github.com/router-for-me/CLIProxyAPI/v7/internal/logging" "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" @@ -306,14 +306,10 @@ func failFromErrors(errs ...error) usage.Failure { if err == nil { continue } - fail := usage.Failure{ - Body: strings.TrimSpace(err.Error()), + return usage.Failure{ + Body: strings.TrimSpace(err.Error()), + StatusCode: clienterror.HTTPStatusFromError(err), } - var se interface{ StatusCode() int } - if errors.As(err, &se) && se != nil { - fail.StatusCode = se.StatusCode() - } - return fail } return usage.Failure{} } diff --git a/internal/runtime/executor/helps/usage_helpers_test.go b/internal/runtime/executor/helps/usage_helpers_test.go index aa41f6360..93a5ab13c 100644 --- a/internal/runtime/executor/helps/usage_helpers_test.go +++ b/internal/runtime/executor/helps/usage_helpers_test.go @@ -2,12 +2,15 @@ package helps import ( "context" + "errors" "io" "net/http" + "net/url" "strings" "testing" "time" + "github.com/router-for-me/CLIProxyAPI/v7/internal/clienterror" "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/usage" ) @@ -660,6 +663,39 @@ func TestUsageReporterBuildAdditionalModelRecordSkipsZeroTokens(t *testing.T) { } } +func TestFailFromErrorsMapsContextStatuses(t *testing.T) { + tests := []struct { + name string + err error + want int + }{ + {name: "canceled", err: context.Canceled, want: clienterror.StatusClientClosedRequest}, + {name: "deadline", err: context.DeadlineExceeded, want: http.StatusGatewayTimeout}, + { + name: "url error wraps canceled", + err: &url.Error{Op: "Post", URL: "https://example.com", Err: context.Canceled}, + want: clienterror.StatusClientClosedRequest, + }, + {name: "plain error", err: errors.New("boom"), want: 0}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + fail := failFromErrors(tc.err) + if fail.StatusCode != tc.want { + t.Fatalf("StatusCode = %d, want %d; body=%q", fail.StatusCode, tc.want, fail.Body) + } + if strings.TrimSpace(fail.Body) == "" { + t.Fatalf("expected non-empty failure body") + } + }) + } + + if fail := failFromErrors(nil, nil); fail.StatusCode != 0 || fail.Body != "" { + t.Fatalf("failFromErrors(nil) = %+v, want empty failure", fail) + } +} + type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { diff --git a/sdk/api/handlers/handlers_error_response_test.go b/sdk/api/handlers/handlers_error_response_test.go index 561d98d62..c52539016 100644 --- a/sdk/api/handlers/handlers_error_response_test.go +++ b/sdk/api/handlers/handlers_error_response_test.go @@ -1,15 +1,18 @@ package handlers import ( + "context" "errors" "net/http" "net/http/httptest" + "net/url" "reflect" "strings" "testing" "time" "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v7/internal/clienterror" "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" @@ -209,3 +212,69 @@ func TestEnrichAuthSelectionError_IgnoresOtherErrors(t *testing.T) { t.Fatalf("expected original error to be returned unchanged") } } + +func TestExecutionErrorMessageMapsContextStatuses(t *testing.T) { + tests := []struct { + name string + err error + want int + }{ + {name: "canceled", err: context.Canceled, want: clienterror.StatusClientClosedRequest}, + {name: "deadline", err: context.DeadlineExceeded, want: http.StatusGatewayTimeout}, + { + name: "url error wraps canceled", + err: &url.Error{Op: "Post", URL: "https://example.com", Err: context.Canceled}, + want: clienterror.StatusClientClosedRequest, + }, + {name: "plain error defaults to 500", err: errors.New("boom"), want: http.StatusInternalServerError}, + { + name: "explicit status wins", + err: &coreauth.Error{Code: "rate_limited", Message: "slow down", HTTPStatus: http.StatusTooManyRequests}, + want: http.StatusTooManyRequests, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + msg := executionErrorMessage(tc.err) + if msg == nil { + t.Fatalf("executionErrorMessage() returned nil") + } + if msg.StatusCode != tc.want { + t.Fatalf("StatusCode = %d, want %d", msg.StatusCode, tc.want) + } + if msg.Error != tc.err { + t.Fatalf("Error = %v, want original %v", msg.Error, tc.err) + } + }) + } +} + +func TestStatusFromErrorMapsContextStatuses(t *testing.T) { + if got := statusFromError(context.Canceled); got != clienterror.StatusClientClosedRequest { + t.Fatalf("statusFromError(canceled) = %d, want %d", got, clienterror.StatusClientClosedRequest) + } + if got := statusFromError(context.DeadlineExceeded); got != http.StatusGatewayTimeout { + t.Fatalf("statusFromError(deadline) = %d, want %d", got, http.StatusGatewayTimeout) + } + if got := statusFromError(&url.Error{Op: "Post", URL: "https://example.com", Err: context.Canceled}); got != clienterror.StatusClientClosedRequest { + t.Fatalf("statusFromError(url canceled) = %d, want %d", got, clienterror.StatusClientClosedRequest) + } + if got := statusFromError(errors.New("boom")); got != 0 { + t.Fatalf("statusFromError(plain) = %d, want 0", got) + } +} + +func TestWriteErrorResponse_ContextCanceledUses499(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + + handler := NewBaseAPIHandlers(nil, nil) + handler.WriteErrorResponse(c, executionErrorMessage(context.Canceled)) + + if recorder.Code != clienterror.StatusClientClosedRequest { + t.Fatalf("status = %d, want %d", recorder.Code, clienterror.StatusClientClosedRequest) + } +} diff --git a/sdk/api/handlers/handlers_errors.go b/sdk/api/handlers/handlers_errors.go index 1dc059139..57df50e04 100644 --- a/sdk/api/handlers/handlers_errors.go +++ b/sdk/api/handlers/handlers_errors.go @@ -8,21 +8,14 @@ import ( "strings" "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v7/internal/clienterror" "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" "golang.org/x/net/context" ) func statusFromError(err error) int { - if err == nil { - return 0 - } - if se, ok := err.(interface{ StatusCode() int }); ok && se != nil { - if code := se.StatusCode(); code > 0 { - return code - } - } - return 0 + return clienterror.HTTPStatusFromError(err) } func isAuthSelectionUnavailable(err error) bool { diff --git a/sdk/api/handlers/handlers_execution.go b/sdk/api/handlers/handlers_execution.go index b2deb73f5..994bd33a2 100644 --- a/sdk/api/handlers/handlers_execution.go +++ b/sdk/api/handlers/handlers_execution.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" + "github.com/router-for-me/CLIProxyAPI/v7/internal/clienterror" "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" coreexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi" @@ -307,10 +308,8 @@ func executionErrorMessage(err error) *interfaces.ErrorMessage { } } status := http.StatusInternalServerError - if se, ok := err.(interface{ StatusCode() int }); ok && se != nil { - if code := se.StatusCode(); code > 0 { - status = code - } + if code := clienterror.HTTPStatusFromError(err); code > 0 { + status = code } var addon http.Header if he, ok := err.(interface{ Headers() http.Header }); ok && he != nil { diff --git a/sdk/api/handlers/openai/openai_images_handlers.go b/sdk/api/handlers/openai/openai_images_handlers.go index 7f65bca1f..961a4276f 100644 --- a/sdk/api/handlers/openai/openai_images_handlers.go +++ b/sdk/api/handlers/openai/openai_images_handlers.go @@ -15,6 +15,7 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v7/internal/clienterror" internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config" "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" @@ -1609,7 +1610,11 @@ func collectImagesFromResponsesStream(ctx context.Context, data <-chan []byte, e for { select { case <-ctx.Done(): - return nil, &interfaces.ErrorMessage{StatusCode: http.StatusRequestTimeout, Error: ctx.Err()} + errCtx := ctx.Err() + return nil, &interfaces.ErrorMessage{ + StatusCode: clienterror.HTTPStatusFromErrorOr(errCtx, http.StatusRequestTimeout), + Error: errCtx, + } case errMsg, ok := <-errs: if ok && errMsg != nil { return nil, errMsg diff --git a/sdk/api/handlers/openai/openai_responses_websocket_forward.go b/sdk/api/handlers/openai/openai_responses_websocket_forward.go index 775f57000..49603edb2 100644 --- a/sdk/api/handlers/openai/openai_responses_websocket_forward.go +++ b/sdk/api/handlers/openai/openai_responses_websocket_forward.go @@ -188,13 +188,10 @@ func responsesWebsocketErrorStatus(errMsg *interfaces.ErrorMessage) int { if errMsg == nil { return 0 } - status := errMsg.StatusCode - if status <= 0 && errMsg.Error != nil { - if se, ok := errMsg.Error.(interface{ StatusCode() int }); ok && se != nil { - status = se.StatusCode() - } + if errMsg.StatusCode > 0 { + return errMsg.StatusCode } - return status + return clienterror.HTTPStatusFromError(errMsg.Error) } // shouldExposeResponsesUpstreamError reports whether a terminal upstream error diff --git a/sdk/api/handlers/openai/openai_videos_handlers.go b/sdk/api/handlers/openai/openai_videos_handlers.go index 6f9e1c3ac..1748eaa6d 100644 --- a/sdk/api/handlers/openai/openai_videos_handlers.go +++ b/sdk/api/handlers/openai/openai_videos_handlers.go @@ -14,6 +14,7 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" + "github.com/router-for-me/CLIProxyAPI/v7/internal/clienterror" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" @@ -891,7 +892,10 @@ func (h *OpenAIAPIHandler) VideosContent(c *gin.Context) { func (h *OpenAIAPIHandler) writeVideoContentFromURL(c *gin.Context, contentURL string) error { req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, contentURL, nil) if err != nil { - errMsg := &interfaces.ErrorMessage{StatusCode: http.StatusBadGateway, Error: err} + errMsg := &interfaces.ErrorMessage{ + StatusCode: clienterror.HTTPStatusFromErrorOr(err, http.StatusBadGateway), + Error: err, + } h.WriteErrorResponse(c, errMsg) return err } @@ -899,7 +903,10 @@ func (h *OpenAIAPIHandler) writeVideoContentFromURL(c *gin.Context, contentURL s httpClient := h.videoContentHTTPClient(c) resp, err := httpClient.Do(req) if err != nil { - errMsg := &interfaces.ErrorMessage{StatusCode: http.StatusBadGateway, Error: err} + errMsg := &interfaces.ErrorMessage{ + StatusCode: clienterror.HTTPStatusFromErrorOr(err, http.StatusBadGateway), + Error: err, + } h.WriteErrorResponse(c, errMsg) return err } diff --git a/sdk/cliproxy/auth/conductor_cooldown.go b/sdk/cliproxy/auth/conductor_cooldown.go index c7c911c4d..5c9acdcd6 100644 --- a/sdk/cliproxy/auth/conductor_cooldown.go +++ b/sdk/cliproxy/auth/conductor_cooldown.go @@ -1273,7 +1273,8 @@ func isConnectionLifecycleError(err error) bool { if statusCodeFromError(err) != 0 { return false } - if errors.Is(err, context.Canceled) || errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + // Client abort and request-scoped timeouts are not credential faults. + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { return true } return isConnectionLifecycleMessage(err.Error()) @@ -1300,7 +1301,7 @@ func isConnectionLifecycleMessage(message string) bool { return false } switch lower { - case "context canceled", "eof", "unexpected eof": + case "context canceled", "context deadline exceeded", "eof", "unexpected eof": return true } // gorilla/websocket CloseError.Error() and common wrappers. diff --git a/sdk/cliproxy/auth/connection_lifecycle_cooldown_test.go b/sdk/cliproxy/auth/connection_lifecycle_cooldown_test.go index 94e53082d..d6e77570a 100644 --- a/sdk/cliproxy/auth/connection_lifecycle_cooldown_test.go +++ b/sdk/cliproxy/auth/connection_lifecycle_cooldown_test.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "net/url" "testing" "time" @@ -29,9 +30,14 @@ func TestManager_MarkResult_ConnectionLifecycleDoesNotCooldown(t *testing.T) { {name: "websocket 1001", err: &Error{Message: "websocket: close 1001 (going away)"}}, {name: "websocket 1006", err: &Error{Message: "websocket: close 1006 (abnormal closure): unexpected EOF"}}, {name: "context canceled", err: &Error{Message: "context canceled"}}, + {name: "context deadline exceeded", err: &Error{Message: "context deadline exceeded"}}, {name: "unexpected EOF", err: &Error{Message: "unexpected EOF"}}, {name: "plain EOF", err: &Error{Message: "EOF"}}, {name: "wrapped unexpected EOF", err: &Error{Message: "read tcp 127.0.0.1:1->127.0.0.1:2: unexpected EOF"}}, + {name: "typed canceled", err: resultErrorFromError(context.Canceled)}, + {name: "typed deadline", err: resultErrorFromError(context.DeadlineExceeded)}, + {name: "url canceled", err: resultErrorFromError(&url.Error{Op: "Post", URL: "https://example.com", Err: context.Canceled})}, + {name: "url deadline", err: resultErrorFromError(&url.Error{Op: "Post", URL: "https://example.com", Err: context.DeadlineExceeded})}, } for _, tc := range cases { @@ -202,8 +208,11 @@ func TestManager_MarkResult_NonLifecycleStillCooldowns(t *testing.T) { func TestResultErrorFromError_ConnectionLifecycleDoesNotBecomeRequestScoped(t *testing.T) { cases := []error{ context.Canceled, + context.DeadlineExceeded, io.EOF, io.ErrUnexpectedEOF, + &url.Error{Op: "Post", URL: "https://example.com", Err: context.Canceled}, + &url.Error{Op: "Post", URL: "https://example.com", Err: context.DeadlineExceeded}, &websocket.CloseError{Code: websocket.CloseNormalClosure, Text: "normal"}, &websocket.CloseError{Code: websocket.CloseGoingAway, Text: "bye"}, &websocket.CloseError{Code: websocket.CloseAbnormalClosure, Text: "unexpected EOF"}, @@ -211,6 +220,7 @@ func TestResultErrorFromError_ConnectionLifecycleDoesNotBecomeRequestScoped(t *t fmt.Errorf("wrap: %w", io.ErrUnexpectedEOF), errors.New("websocket: close 1000 (normal)"), errors.New("websocket: close 1006 (abnormal closure): unexpected EOF"), + errors.New("context deadline exceeded"), errors.New("unexpected EOF"), } for _, err := range cases {