Skip to content

Commit b9d024a

Browse files
committed
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: router-for-me#2886
1 parent 8d2c00c commit b9d024a

3 files changed

Lines changed: 111 additions & 1 deletion

File tree

internal/runtime/executor/codex_executor.go

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,9 @@ func codexTerminalStreamErrShouldHandle(body []byte) bool {
144144
if codexTerminalErrorIsContextLength(body) {
145145
return true
146146
}
147+
if isCodexUsageLimitError(body) || isCodexModelCapacityError(body) {
148+
return true
149+
}
147150
code, _, ok := codexStatusErrorClassification(http.StatusBadRequest, body)
148151
return ok && code == "thinking_signature_invalid"
149152
}
@@ -1672,7 +1675,7 @@ func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, s
16721675

16731676
func newCodexStatusErr(statusCode int, body []byte) statusErr {
16741677
errCode := statusCode
1675-
if isCodexModelCapacityError(body) {
1678+
if isCodexModelCapacityError(body) || isCodexUsageLimitError(body) {
16761679
errCode = http.StatusTooManyRequests
16771680
}
16781681
body = classifyCodexStatusError(errCode, body)
@@ -1819,6 +1822,28 @@ func isCodexModelCapacityError(errorBody []byte) bool {
18191822
return false
18201823
}
18211824

1825+
// isCodexUsageLimitError reports whether the error body represents a Codex
1826+
// quota/plan-limit exhaustion (error.type == "usage_limit_reached"). This is the
1827+
// signal Codex emits when a credential's usage quota is depleted, and it carries
1828+
// reset timing (resets_at/resets_in_seconds) parsed by parseCodexRetryAfter.
1829+
// Transient per-minute rate limits (rate_limit_error/rate_limit_exceeded) are
1830+
// intentionally excluded, as they should be retried rather than cooled down.
1831+
func isCodexUsageLimitError(errorBody []byte) bool {
1832+
if len(errorBody) == 0 {
1833+
return false
1834+
}
1835+
candidates := []string{
1836+
gjson.GetBytes(errorBody, "error.type").String(),
1837+
gjson.GetBytes(errorBody, "type").String(),
1838+
}
1839+
for _, candidate := range candidates {
1840+
if strings.EqualFold(strings.TrimSpace(candidate), "usage_limit_reached") {
1841+
return true
1842+
}
1843+
}
1844+
return false
1845+
}
1846+
18221847
func parseCodexRetryAfter(statusCode int, errorBody []byte, now time.Time) *time.Duration {
18231848
if statusCode != http.StatusTooManyRequests || len(errorBody) == 0 {
18241849
return nil

internal/runtime/executor/codex_executor_retry_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,60 @@ func TestNewCodexStatusErrTreatsCapacityAsRetryableRateLimit(t *testing.T) {
7474
}
7575
}
7676

77+
func TestNewCodexStatusErrTreatsUsageLimitAsRetryableRateLimit(t *testing.T) {
78+
body := []byte(`{"error":{"type":"usage_limit_reached","message":"You've hit your usage limit.","resets_in_seconds":120}}`)
79+
80+
err := newCodexStatusErr(http.StatusBadRequest, body)
81+
82+
if got := err.StatusCode(); got != http.StatusTooManyRequests {
83+
t.Fatalf("status code = %d, want %d", got, http.StatusTooManyRequests)
84+
}
85+
retryAfter := err.RetryAfter()
86+
if retryAfter == nil {
87+
t.Fatalf("expected retryAfter from usage_limit_reached, got nil")
88+
}
89+
if *retryAfter != 120*time.Second {
90+
t.Fatalf("retryAfter = %v, want %v", *retryAfter, 120*time.Second)
91+
}
92+
}
93+
94+
func TestIsCodexUsageLimitError(t *testing.T) {
95+
tests := []struct {
96+
name string
97+
body []byte
98+
want bool
99+
}{
100+
{
101+
name: "nested usage_limit_reached",
102+
body: []byte(`{"error":{"type":"usage_limit_reached","resets_in_seconds":30}}`),
103+
want: true,
104+
},
105+
{
106+
name: "top-level usage_limit_reached",
107+
body: []byte(`{"type":"usage_limit_reached"}`),
108+
want: true,
109+
},
110+
{
111+
name: "transient rate limit is excluded",
112+
body: []byte(`{"error":{"type":"rate_limit_error","code":"rate_limit_exceeded"}}`),
113+
want: false,
114+
},
115+
{
116+
name: "empty body",
117+
body: nil,
118+
want: false,
119+
},
120+
}
121+
122+
for _, tc := range tests {
123+
t.Run(tc.name, func(t *testing.T) {
124+
if got := isCodexUsageLimitError(tc.body); got != tc.want {
125+
t.Fatalf("isCodexUsageLimitError = %v, want %v", got, tc.want)
126+
}
127+
})
128+
}
129+
}
130+
77131
func TestNewCodexStatusErrClassifiesKnownCodexFailures(t *testing.T) {
78132
tests := []struct {
79133
name string

internal/runtime/executor/codex_executor_stream_output_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"net/http/httptest"
88
"strings"
99
"testing"
10+
"time"
1011

1112
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
1213
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator"
@@ -166,6 +167,36 @@ func TestCodexTerminalStreamErrIgnoresRateLimitTerminalErrors(t *testing.T) {
166167
}
167168
}
168169

170+
func TestCodexTerminalStreamErrHandlesUsageLimitErrorEvent(t *testing.T) {
171+
streamErr, _, ok := codexTerminalStreamErr([]byte(`{"type":"error","error":{"type":"usage_limit_reached","message":"You've hit your usage limit.","resets_in_seconds":300}}`))
172+
if !ok {
173+
t.Fatal("expected usage_limit_reached terminal error to be handled")
174+
}
175+
if got := statusCodeFromTestError(t, streamErr); got != http.StatusTooManyRequests {
176+
t.Fatalf("status code = %d, want %d", got, http.StatusTooManyRequests)
177+
}
178+
retryAfter := streamErr.RetryAfter()
179+
if retryAfter == nil {
180+
t.Fatal("expected retryAfter from usage_limit_reached terminal error")
181+
}
182+
if *retryAfter != 300*time.Second {
183+
t.Fatalf("retryAfter = %v, want %v", *retryAfter, 300*time.Second)
184+
}
185+
}
186+
187+
func TestCodexTerminalStreamErrHandlesUsageLimitResponseFailed(t *testing.T) {
188+
streamErr, _, ok := codexTerminalStreamErr([]byte(`{"type":"response.failed","response":{"error":{"type":"usage_limit_reached","message":"usage limit reached","resets_in_seconds":60}}}`))
189+
if !ok {
190+
t.Fatal("expected usage_limit_reached response.failed terminal error to be handled")
191+
}
192+
if got := statusCodeFromTestError(t, streamErr); got != http.StatusTooManyRequests {
193+
t.Fatalf("status code = %d, want %d", got, http.StatusTooManyRequests)
194+
}
195+
if streamErr.RetryAfter() == nil {
196+
t.Fatal("expected retryAfter from usage_limit_reached response.failed terminal error")
197+
}
198+
}
199+
169200
func statusCodeFromTestError(t *testing.T, err error) int {
170201
t.Helper()
171202

0 commit comments

Comments
 (0)