Skip to content

Commit 7913502

Browse files
fix(executor): preserve retry-after semantics for SSE usage_limit_reached errors
1 parent 39fb7bd commit 7913502

File tree

2 files changed

+88
-1
lines changed

2 files changed

+88
-1
lines changed

internal/runtime/executor/codex_executor.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -767,7 +767,26 @@ func parseCodexSSEError(line []byte) (statusErr, bool) {
767767
if err != nil {
768768
return statusErr{}, false
769769
}
770-
return statusErr{code: status, msg: string(encoded)}, true
770+
var retryAfter *time.Duration
771+
if status == http.StatusTooManyRequests {
772+
retryAfter = parseSSERetryAfter(errorPayload, time.Now())
773+
}
774+
return statusErr{code: status, msg: string(encoded), retryAfter: retryAfter}, true
775+
}
776+
777+
func parseSSERetryAfter(errorPayload gjson.Result, now time.Time) *time.Duration {
778+
if resetsAt := errorPayload.Get("resets_at").Int(); resetsAt > 0 {
779+
resetTime := time.Unix(resetsAt, 0)
780+
if resetTime.After(now) {
781+
d := resetTime.Sub(now)
782+
return &d
783+
}
784+
}
785+
if resetsInSeconds := errorPayload.Get("resets_in_seconds").Int(); resetsInSeconds > 0 {
786+
d := time.Duration(resetsInSeconds) * time.Second
787+
return &d
788+
}
789+
return nil
771790
}
772791

773792
func codexCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) {

internal/runtime/executor/codex_executor_retry_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,74 @@ func TestParseCodexSSEError(t *testing.T) {
131131
t.Fatalf("expected non-error event to be ignored")
132132
}
133133
})
134+
135+
t.Run("usage_limit_reached with resets_at preserves retryAfter", func(t *testing.T) {
136+
resetAt := time.Now().Add(5 * time.Minute).Unix()
137+
line := []byte(`{"type":"error","error":{"type":"usage_limit_reached","code":"usage_limit_reached","message":"usage limit reached","resets_at":` + itoa(resetAt) + `,"resets_in_seconds":60}}`)
138+
got, ok := parseCodexSSEError(line)
139+
if !ok {
140+
t.Fatalf("expected parser to handle usage_limit_reached SSE error")
141+
}
142+
if got.code != http.StatusTooManyRequests {
143+
t.Fatalf("status = %d, want %d", got.code, http.StatusTooManyRequests)
144+
}
145+
ra := got.RetryAfter()
146+
if ra == nil {
147+
t.Fatalf("expected retryAfter to be set, got nil")
148+
}
149+
if *ra < 4*time.Minute || *ra > 6*time.Minute {
150+
t.Fatalf("retryAfter = %v, want ~5m", *ra)
151+
}
152+
})
153+
154+
t.Run("usage_limit_reached with resets_in_seconds only", func(t *testing.T) {
155+
line := []byte(`{"type":"error","error":{"type":"usage_limit_reached","code":"usage_limit_reached","message":"usage limit reached","resets_in_seconds":120}}`)
156+
got, ok := parseCodexSSEError(line)
157+
if !ok {
158+
t.Fatalf("expected parser to handle usage_limit_reached SSE error")
159+
}
160+
ra := got.RetryAfter()
161+
if ra == nil {
162+
t.Fatalf("expected retryAfter to be set, got nil")
163+
}
164+
if *ra != 120*time.Second {
165+
t.Fatalf("retryAfter = %v, want %v", *ra, 120*time.Second)
166+
}
167+
})
168+
169+
t.Run("response.failed usage_limit_reached preserves retryAfter", func(t *testing.T) {
170+
line := []byte(`{"type":"response.failed","response":{"error":{"type":"usage_limit_reached","code":"usage_limit_reached","message":"usage limit reached","resets_in_seconds":90}}}`)
171+
got, ok := parseCodexSSEError(line)
172+
if !ok {
173+
t.Fatalf("expected parser to handle response.failed usage_limit_reached")
174+
}
175+
if got.code != http.StatusTooManyRequests {
176+
t.Fatalf("status = %d, want %d", got.code, http.StatusTooManyRequests)
177+
}
178+
ra := got.RetryAfter()
179+
if ra == nil {
180+
t.Fatalf("expected retryAfter to be set, got nil")
181+
}
182+
if *ra != 90*time.Second {
183+
t.Fatalf("retryAfter = %v, want %v", *ra, 90*time.Second)
184+
}
185+
})
186+
187+
t.Run("usage_limit_reached with past resets_at falls back to resets_in_seconds", func(t *testing.T) {
188+
resetAt := time.Now().Add(-1 * time.Minute).Unix()
189+
line := []byte(`{"type":"error","error":{"type":"usage_limit_reached","code":"usage_limit_reached","message":"usage limit reached","resets_at":` + itoa(resetAt) + `,"resets_in_seconds":45}}`)
190+
got, ok := parseCodexSSEError(line)
191+
if !ok {
192+
t.Fatalf("expected parser to handle usage_limit_reached SSE error")
193+
}
194+
ra := got.RetryAfter()
195+
if ra == nil {
196+
t.Fatalf("expected retryAfter to be set, got nil")
197+
}
198+
if *ra != 45*time.Second {
199+
t.Fatalf("retryAfter = %v, want %v", *ra, 45*time.Second)
200+
}
201+
})
134202
}
135203

136204
func itoa(v int64) string {

0 commit comments

Comments
 (0)