mirror of
https://gh-proxy.org/https://github.com/klinsmaya/CLIProxyAPI.git
synced 2026-11-10 02:16:49 +08:00
feat(executor): handle usage limit errors and enhance retry logic
- Added `isCodexUsageLimitError` to detect and handle `usage_limit_reached` errors from Codex responses. - Updated `newCodexStatusErr` to treat usage limit errors as HTTP 429 with proper `RetryAfter` handling. - Enhanced test coverage to validate usage limit error handling, including reset time parsing and retry behavior. Closes: #2886
This commit is contained in:
@@ -144,6 +144,9 @@ func codexTerminalStreamErrShouldHandle(body []byte) bool {
|
||||
if codexTerminalErrorIsContextLength(body) {
|
||||
return true
|
||||
}
|
||||
if isCodexUsageLimitError(body) || isCodexModelCapacityError(body) {
|
||||
return true
|
||||
}
|
||||
code, _, ok := codexStatusErrorClassification(http.StatusBadRequest, body)
|
||||
return ok && code == "thinking_signature_invalid"
|
||||
}
|
||||
@@ -1672,7 +1675,7 @@ func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, s
|
||||
|
||||
func newCodexStatusErr(statusCode int, body []byte) statusErr {
|
||||
errCode := statusCode
|
||||
if isCodexModelCapacityError(body) {
|
||||
if isCodexModelCapacityError(body) || isCodexUsageLimitError(body) {
|
||||
errCode = http.StatusTooManyRequests
|
||||
}
|
||||
body = classifyCodexStatusError(errCode, body)
|
||||
@@ -1819,6 +1822,28 @@ func isCodexModelCapacityError(errorBody []byte) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// isCodexUsageLimitError reports whether the error body represents a Codex
|
||||
// quota/plan-limit exhaustion (error.type == "usage_limit_reached"). This is the
|
||||
// signal Codex emits when a credential's usage quota is depleted, and it carries
|
||||
// reset timing (resets_at/resets_in_seconds) parsed by parseCodexRetryAfter.
|
||||
// Transient per-minute rate limits (rate_limit_error/rate_limit_exceeded) are
|
||||
// intentionally excluded, as they should be retried rather than cooled down.
|
||||
func isCodexUsageLimitError(errorBody []byte) bool {
|
||||
if len(errorBody) == 0 {
|
||||
return false
|
||||
}
|
||||
candidates := []string{
|
||||
gjson.GetBytes(errorBody, "error.type").String(),
|
||||
gjson.GetBytes(errorBody, "type").String(),
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if strings.EqualFold(strings.TrimSpace(candidate), "usage_limit_reached") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseCodexRetryAfter(statusCode int, errorBody []byte, now time.Time) *time.Duration {
|
||||
if statusCode != http.StatusTooManyRequests || len(errorBody) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -74,6 +74,60 @@ func TestNewCodexStatusErrTreatsCapacityAsRetryableRateLimit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCodexStatusErrTreatsUsageLimitAsRetryableRateLimit(t *testing.T) {
|
||||
body := []byte(`{"error":{"type":"usage_limit_reached","message":"You've hit your usage limit.","resets_in_seconds":120}}`)
|
||||
|
||||
err := newCodexStatusErr(http.StatusBadRequest, body)
|
||||
|
||||
if got := err.StatusCode(); got != http.StatusTooManyRequests {
|
||||
t.Fatalf("status code = %d, want %d", got, http.StatusTooManyRequests)
|
||||
}
|
||||
retryAfter := err.RetryAfter()
|
||||
if retryAfter == nil {
|
||||
t.Fatalf("expected retryAfter from usage_limit_reached, got nil")
|
||||
}
|
||||
if *retryAfter != 120*time.Second {
|
||||
t.Fatalf("retryAfter = %v, want %v", *retryAfter, 120*time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCodexUsageLimitError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body []byte
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "nested usage_limit_reached",
|
||||
body: []byte(`{"error":{"type":"usage_limit_reached","resets_in_seconds":30}}`),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "top-level usage_limit_reached",
|
||||
body: []byte(`{"type":"usage_limit_reached"}`),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "transient rate limit is excluded",
|
||||
body: []byte(`{"error":{"type":"rate_limit_error","code":"rate_limit_exceeded"}}`),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty body",
|
||||
body: nil,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := isCodexUsageLimitError(tc.body); got != tc.want {
|
||||
t.Fatalf("isCodexUsageLimitError = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCodexStatusErrClassifiesKnownCodexFailures(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator"
|
||||
@@ -166,6 +167,36 @@ func TestCodexTerminalStreamErrIgnoresRateLimitTerminalErrors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexTerminalStreamErrHandlesUsageLimitErrorEvent(t *testing.T) {
|
||||
streamErr, _, ok := codexTerminalStreamErr([]byte(`{"type":"error","error":{"type":"usage_limit_reached","message":"You've hit your usage limit.","resets_in_seconds":300}}`))
|
||||
if !ok {
|
||||
t.Fatal("expected usage_limit_reached terminal error to be handled")
|
||||
}
|
||||
if got := statusCodeFromTestError(t, streamErr); got != http.StatusTooManyRequests {
|
||||
t.Fatalf("status code = %d, want %d", got, http.StatusTooManyRequests)
|
||||
}
|
||||
retryAfter := streamErr.RetryAfter()
|
||||
if retryAfter == nil {
|
||||
t.Fatal("expected retryAfter from usage_limit_reached terminal error")
|
||||
}
|
||||
if *retryAfter != 300*time.Second {
|
||||
t.Fatalf("retryAfter = %v, want %v", *retryAfter, 300*time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexTerminalStreamErrHandlesUsageLimitResponseFailed(t *testing.T) {
|
||||
streamErr, _, ok := codexTerminalStreamErr([]byte(`{"type":"response.failed","response":{"error":{"type":"usage_limit_reached","message":"usage limit reached","resets_in_seconds":60}}}`))
|
||||
if !ok {
|
||||
t.Fatal("expected usage_limit_reached response.failed terminal error to be handled")
|
||||
}
|
||||
if got := statusCodeFromTestError(t, streamErr); got != http.StatusTooManyRequests {
|
||||
t.Fatalf("status code = %d, want %d", got, http.StatusTooManyRequests)
|
||||
}
|
||||
if streamErr.RetryAfter() == nil {
|
||||
t.Fatal("expected retryAfter from usage_limit_reached response.failed terminal error")
|
||||
}
|
||||
}
|
||||
|
||||
func statusCodeFromTestError(t *testing.T, err error) int {
|
||||
t.Helper()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user