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:
Luis Pater
2026-06-17 07:19:02 +08:00
parent 8d2c00c107
commit b9d024af49
3 changed files with 111 additions and 1 deletions
+26 -1
View File
@@ -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()