Skip to content

Commit 2dfd8d1

Browse files
itkoneneca-agent
andcommitted
Fix Codex OAuth rate limit reset handling
Use the authoritative usage-limit response reset and bound structured rate-limit waits so quota snapshot metadata cannot stall sessions for hours. 🤖 Generated with [ECA](https://eca.dev) (openai/gpt-5.6-sol - xhigh) Co-Authored-By: eca-agent <git@eca.dev>
1 parent cb47895 commit 2dfd8d1

7 files changed

Lines changed: 203 additions & 68 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## Unreleased
44

5+
- Fix ChatGPT/Codex OAuth rate-limit retries using the response reset instead of quota snapshot headers; cap waits at 60 seconds by default.
56
- Fix prompt cache invalidation warning after clearing the chat and changing model. #530
67
- Fix missing line break after "Prompt stopped" message when followed by another system message.
78

docs/config.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -696,8 +696,10 @@
696696
},
697697
"rateLimitMaxWaitSeconds": {
698698
"type": "integer",
699-
"description": "Optional cap on how long to wait for a rate limit reset (derived from provider response headers like retry-after). Unset: ECA sleeps until the reset time and auto-resumes, showing the reset time in chat. When set and the reset is further away, ECA gives up retrying and surfaces the reset time in the error.",
700-
"markdownDescription": "Optional cap on how long to wait for a rate limit reset (derived from provider response headers like `retry-after`). Unset: ECA sleeps until the reset time and auto-resumes, showing the reset time in chat. When set and the reset is further away, ECA gives up retrying and surfaces the reset time in the error."
699+
"minimum": 0,
700+
"default": 60,
701+
"description": "Maximum total delay before retrying a provider rate limit, including ECA's one-second safety buffer. The delay is derived from retry-after or provider-specific reset data and defaults to a 60-second cap. When the total delay exceeds the cap, ECA gives up retrying and surfaces the reset time in the error.",
702+
"markdownDescription": "Maximum total delay before retrying a provider rate limit, including ECA's one-second safety buffer. The delay is derived from `retry-after` or provider-specific reset data and defaults to a 60-second cap. When the total delay exceeds the cap, ECA gives up retrying and surfaces the reset time in the error."
701703
},
702704
"models": {
703705
"type": "object",

src/eca/llm_api.clj

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
(def ^:private default-base-delay-ms 2000)
4040
(def ^:private default-backoff-multiplier 2)
4141
(def ^:private max-delay-ms 60000)
42+
(def ^:private default-rate-limit-max-wait-seconds 60)
4243
(def ^:private rate-limit-wait-buffer-ms 1000)
4344
(def ^:private cancel-check-interval-ms 100)
4445

@@ -508,22 +509,27 @@
508509
default-max-retries)
509510
rl-wait (when (= :rate-limited error-type)
510511
(llm-providers.errors/rate-limit-wait (:headers error-data)
512+
(:body error-data)
511513
(System/currentTimeMillis)))
512-
max-wait-ms (some-> (:rateLimitMaxWaitSeconds provider-config) long (* 1000))
513-
wait-too-long? (boolean (and rl-wait
514-
max-wait-ms
515-
(> (long (:delay-ms rl-wait)) (long max-wait-ms))))]
514+
rate-limit-delay-ms (some-> rl-wait
515+
:delay-ms
516+
long
517+
(+ rate-limit-wait-buffer-ms))
518+
max-wait-ms (* 1000
519+
(long (or (:rateLimitMaxWaitSeconds provider-config)
520+
default-rate-limit-max-wait-seconds)))
521+
wait-too-long? (boolean (and rate-limit-delay-ms
522+
(> rate-limit-delay-ms max-wait-ms)))]
516523
(if (and (contains? #{:rate-limited :overloaded :retryable-custom :premature-stop} error-type)
517524
(< attempt max-retries)
518525
(not wait-too-long?)
519526
(not (cancelled?)))
520-
(let [delay-ms (if rl-wait
521-
(+ (long (:delay-ms rl-wait)) rate-limit-wait-buffer-ms)
522-
(retry-delay-ms attempt))]
527+
(let [delay-ms (or rate-limit-delay-ms
528+
(retry-delay-ms attempt))]
523529
(logger/info logger-tag
524530
(format "Retryable error (attempt %d/%d), retrying in %ds%s"
525531
(inc attempt) max-retries (quot delay-ms 1000)
526-
(if rl-wait " (rate limit reset from headers)" ""))
532+
(if rl-wait " (rate limit reset from provider response)" ""))
527533
{:error-type error-type
528534
:status (:status error-data)})
529535
(when on-retry

src/eca/llm_providers/errors.clj

Lines changed: 51 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
Supports context overflow, rate limiting, authentication, and overload detection
55
across multiple providers (Anthropic, OpenAI, Google, Ollama, etc.)."
66
(:require
7+
[cheshire.core :as json]
78
[clojure.string :as string]))
89

910
(set! *warn-on-reflection* true)
@@ -203,23 +204,30 @@
203204
(reduce +)
204205
long)))
205206

206-
(defn ^:private codex-window-reset-ms
207-
"ChatGPT Codex quota headers (`x-codex-<window>-*`): latest reset epoch-ms
208-
among exhausted windows (used-percent >= 100), nil otherwise. Windows with
209-
headroom are ignored on purpose: these headers come on every response with
210-
far-future resets, so only a clearly exhausted window is a wait signal."
211-
[headers now-ms]
212-
(some->> ["primary" "secondary"]
213-
(keep (fn [w]
214-
(let [used (some-> (header-str headers (str "x-codex-" w "-used-percent"))
215-
parse-double)
216-
reset-at (epoch-str->ms (header-str headers (str "x-codex-" w "-reset-at")))
217-
reset-after-s (parse-long-str (header-str headers (str "x-codex-" w "-reset-after-seconds")))]
218-
(when (and used (>= used 100.0))
219-
(or reset-at
220-
(some-> reset-after-s (* 1000) (+ (long now-ms))))))))
221-
seq
222-
(apply max)))
207+
(defn ^:private response-field [m field]
208+
(when (map? m)
209+
(or (get m field)
210+
(get m (keyword field)))))
211+
212+
(defn ^:private codex-usage-limit-reset-ms
213+
"ChatGPT Codex OAuth usage-limit reset from the 429 JSON response body."
214+
[body]
215+
(let [body (cond
216+
(map? body) body
217+
(string? body) (try
218+
(json/parse-string body)
219+
(catch Exception _ nil))
220+
:else nil)
221+
error (response-field body "error")
222+
error-type (response-field error "type")
223+
reset-at (response-field error "resets_at")]
224+
(when (and (= "usage_limit_reached" error-type)
225+
(integer? reset-at))
226+
(* (long reset-at) 1000))))
227+
228+
(defn ^:private future-reset-ms [reset-ms now-ms]
229+
(when (and reset-ms (> (long reset-ms) (long now-ms)))
230+
(long reset-ms)))
223231

224232
(defn ^:private bucket-resets
225233
"All rate-limit reset headers (`*ratelimit*-reset` like Anthropic's or
@@ -240,29 +248,36 @@
240248
headers))
241249

242250
(defn rate-limit-wait
243-
"Computes when a rate-limited request can be retried, from HTTP response
244-
headers (lowercase string keys, as returned by the http client).
251+
"Computes when a rate-limited request can be retried.
252+
253+
The two-argument arity reads HTTP response headers. The three-argument arity
254+
additionally reads the response body used by ChatGPT Codex OAuth errors.
245255
246256
Precedence:
247257
1. `retry-after` (delta seconds or HTTP-date)
248-
2. `anthropic-ratelimit-unified-reset` (epoch seconds; subscription session limits)
249-
3. `x-codex-<window>-reset-at/-reset-after-seconds` (ChatGPT subscription),
250-
only for exhausted windows (used-percent >= 100)
258+
2. ChatGPT Codex `usage_limit_reached` body `error.resets_at` (epoch seconds)
259+
3. `anthropic-ratelimit-unified-reset` (epoch seconds; subscription session limits)
251260
4. rate-limit reset buckets (Anthropic `*ratelimit*-reset` RFC 3339/epoch,
252261
OpenAI `*ratelimit*-reset-<bucket>` Go-style durations): latest exhausted
253262
bucket (remaining = 0), else earliest future reset.
254263
255-
Returns {:delay-ms N :resets-at epoch-ms} or nil when headers give no usable
256-
future reset (callers should fall back to exponential backoff)."
257-
[headers now-ms]
258-
(when (map? headers)
259-
(let [resets-at (or (retry-after-ms headers now-ms)
260-
(epoch-str->ms (header-str headers "anthropic-ratelimit-unified-reset"))
261-
(codex-window-reset-ms headers now-ms)
262-
(let [buckets (filter #(> (long (:reset-ms %)) (long now-ms)) (bucket-resets headers now-ms))
263-
exhausted (filter #(some-> (:remaining %) (<= 0)) buckets)]
264-
(or (some->> (seq exhausted) (map :reset-ms) (apply max))
265-
(some->> (seq buckets) (map :reset-ms) (apply min)))))]
266-
(when (and resets-at (> (long resets-at) (long now-ms)))
267-
{:delay-ms (- (long resets-at) (long now-ms))
268-
:resets-at (long resets-at)}))))
264+
Codex quota-window headers are status snapshots, not retry instructions.
265+
266+
Returns {:delay-ms N :resets-at epoch-ms} or nil when no usable future reset
267+
is present (callers should fall back to exponential backoff)."
268+
([headers now-ms]
269+
(rate-limit-wait headers nil now-ms))
270+
([headers body now-ms]
271+
(let [headers (if (map? headers) headers {})
272+
buckets (filter #(> (long (:reset-ms %)) (long now-ms)) (bucket-resets headers now-ms))
273+
exhausted (filter #(some-> (:remaining %) (<= 0)) buckets)
274+
bucket-reset (or (some->> (seq exhausted) (map :reset-ms) (apply max))
275+
(some->> (seq buckets) (map :reset-ms) (apply min)))
276+
resets-at (some #(future-reset-ms % now-ms)
277+
[(retry-after-ms headers now-ms)
278+
(codex-usage-limit-reset-ms body)
279+
(epoch-str->ms (header-str headers "anthropic-ratelimit-unified-reset"))
280+
bucket-reset])]
281+
(when resets-at
282+
{:delay-ms (- resets-at (long now-ms))
283+
:resets-at resets-at}))))

test/eca/llm_api_test.clj

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -704,8 +704,32 @@
704704
(is (= 8000 (:delay-ms event)))
705705
(is (number? (:resets-at event)))))))
706706

707-
(deftest sync-rate-limit-waits-until-reset-without-cap-test
708-
(testing "without rateLimitMaxWaitSeconds config, waits the full header reset even when long"
707+
(deftest sync-rate-limit-default-max-wait-test
708+
(testing "default rateLimitMaxWaitSeconds rejects a long Codex usage-limit reset"
709+
(let [attempt* (atom 0)
710+
error* (atom nil)
711+
slept* (atom [])]
712+
(with-redefs [eca.llm-api/prompt! (fn [_]
713+
(swap! attempt* inc)
714+
(let [reset-epoch-s (+ (quot (System/currentTimeMillis) 1000) 7200)]
715+
{:error {:status 429
716+
:body {:error {:type "usage_limit_reached"
717+
:resets_at reset-epoch-s}}
718+
:message "OpenAI response status: 429"}}))
719+
eca.llm-api/sleep-with-cancel (fn [delay-ms _]
720+
(swap! slept* conj delay-ms)
721+
true)]
722+
(llm-api/sync-or-async-prompt!
723+
(make-prompt-opts
724+
{:stream false
725+
:on-error (fn [error] (reset! error* error))
726+
:on-message-received identity})))
727+
(is (= 1 @attempt*))
728+
(is (empty? @slept*))
729+
(is (number? (:rate-limit-resets-at @error*))))))
730+
731+
(deftest rate-limit-default-max-wait-buffer-boundary-test
732+
(testing "59-second provider reset is allowed because the buffered delay is exactly 60 seconds"
709733
(let [attempt* (atom 0)
710734
slept* (atom [])]
711735
(with-redefs [eca.llm-api/prompt! (fn [_]
@@ -714,7 +738,7 @@
714738
{:error {:status 429
715739
:body "Rate limit exceeded"
716740
:message "LLM response status: 429"
717-
:headers {"retry-after" "7200"}}}
741+
:headers {"retry-after" "59"}}}
718742
{:output-text "success"
719743
:usage {:input-tokens 1 :output-tokens 1}})))
720744
eca.llm-api/sleep-with-cancel (fn [delay-ms cancelled?]
@@ -726,7 +750,29 @@
726750
:on-error identity
727751
:on-message-received identity})))
728752
(is (= 2 @attempt*))
729-
(is (= [7201000] @slept*) "2h from retry-after + 1s buffer, not capped"))))
753+
(is (= [60000] @slept*))))
754+
755+
(testing "60-second provider reset is rejected because the safety buffer makes 61 seconds"
756+
(let [attempt* (atom 0)
757+
error* (atom nil)
758+
slept* (atom [])]
759+
(with-redefs [eca.llm-api/prompt! (fn [_]
760+
(swap! attempt* inc)
761+
{:error {:status 429
762+
:body "Rate limit exceeded"
763+
:message "LLM response status: 429"
764+
:headers {"retry-after" "60"}}})
765+
eca.llm-api/sleep-with-cancel (fn [delay-ms _]
766+
(swap! slept* conj delay-ms)
767+
true)]
768+
(llm-api/sync-or-async-prompt!
769+
(make-prompt-opts
770+
{:stream false
771+
:on-error (fn [error] (reset! error* error))
772+
:on-message-received identity})))
773+
(is (= 1 @attempt*))
774+
(is (empty? @slept*))
775+
(is (number? (:rate-limit-resets-at @error*))))))
730776

731777
(deftest rate-limit-max-wait-config-override-test
732778
(testing "provider rateLimitMaxWaitSeconds lower than reset wait disables the retry, exposing resets-at"

test/eca/llm_providers/errors_test.clj

Lines changed: 62 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -399,33 +399,77 @@
399399
(llm-providers.errors/rate-limit-wait
400400
{"x-ratelimit-reset" (str (+ now-ms 5000))} now-ms))))
401401

402-
(testing "chatgpt codex exhausted window uses reset-at epoch"
403-
(let [reset-epoch-s (+ (quot now-ms 1000) 1800)]
404-
(is (= {:delay-ms 1800000 :resets-at (* reset-epoch-s 1000)}
402+
(testing "chatgpt codex usage-limit reset accepts raw and decoded response bodies"
403+
(let [reset-epoch-s (+ (quot now-ms 1000) 1800)
404+
expected {:delay-ms 1800000 :resets-at (* reset-epoch-s 1000)}
405+
headers {"x-codex-primary-used-percent" "100"
406+
"x-codex-primary-reset-at" (str (+ reset-epoch-s 3600))}]
407+
(doseq [[label body]
408+
[["raw JSON string"
409+
(str "{\"error\":{\"type\":\"usage_limit_reached\",\"resets_at\":" reset-epoch-s "}}")]
410+
["keyword-keyed decoded map"
411+
{:error {:type "usage_limit_reached" :resets_at reset-epoch-s}}]
412+
["string-keyed decoded map"
413+
{"error" {"type" "usage_limit_reached" "resets_at" reset-epoch-s}}]]]
414+
(is (= expected
415+
(llm-providers.errors/rate-limit-wait headers body now-ms))
416+
label))))
417+
418+
(testing "future retry-after takes precedence over chatgpt codex body reset"
419+
(let [reset-epoch-s (+ (quot now-ms 1000) 1800)
420+
body {:error {:type "usage_limit_reached" :resets_at reset-epoch-s}}]
421+
(is (= {:delay-ms 7000 :resets-at (+ now-ms 7000)}
405422
(llm-providers.errors/rate-limit-wait
406-
{"x-codex-primary-used-percent" "100"
407-
"x-codex-primary-reset-at" (str reset-epoch-s)
408-
"x-codex-secondary-used-percent" "0"} now-ms)))))
409-
410-
(testing "chatgpt codex exhausted window falls back to reset-after-seconds"
411-
(is (= {:delay-ms 3600000 :resets-at (+ now-ms 3600000)}
412-
(llm-providers.errors/rate-limit-wait
413-
{"x-codex-primary-used-percent" "100"
414-
"x-codex-primary-reset-at" ""
415-
"x-codex-primary-reset-after-seconds" "3600"} now-ms))))
423+
{"retry-after" "7"}
424+
body
425+
now-ms)))))
426+
427+
(testing "expired retry-after falls through to a future chatgpt codex body reset"
428+
(let [reset-epoch-s (+ (quot now-ms 1000) 1800)
429+
body {:error {:type "usage_limit_reached" :resets_at reset-epoch-s}}
430+
expired-http-date (.format java.time.format.DateTimeFormatter/RFC_1123_DATE_TIME
431+
(.atZone (java.time.Instant/ofEpochMilli (- now-ms 60000))
432+
(java.time.ZoneId/of "GMT")))]
433+
(doseq [retry-after ["0" expired-http-date]]
434+
(is (= {:delay-ms 1800000 :resets-at (* reset-epoch-s 1000)}
435+
(llm-providers.errors/rate-limit-wait
436+
{"retry-after" retry-after}
437+
body
438+
now-ms))))))
439+
440+
(testing "past chatgpt codex body reset falls through to a future lower-priority reset"
441+
(let [past-reset-epoch-s (- (quot now-ms 1000) 60)
442+
future-reset-epoch-s (+ (quot now-ms 1000) 30)]
443+
(is (= {:delay-ms 30000 :resets-at (* future-reset-epoch-s 1000)}
444+
(llm-providers.errors/rate-limit-wait
445+
{"anthropic-ratelimit-unified-reset" (str future-reset-epoch-s)}
446+
{:error {:type "usage_limit_reached" :resets_at past-reset-epoch-s}}
447+
now-ms)))))
416448

417-
(testing "chatgpt codex windows with headroom are not a wait signal"
449+
(testing "chatgpt codex quota-window headers are not retry instructions"
418450
(is (nil? (llm-providers.errors/rate-limit-wait
419-
{"x-codex-primary-used-percent" "0"
420-
"x-codex-primary-reset-at" (str (+ (quot now-ms 1000) 604800))
421-
"x-codex-primary-reset-after-seconds" "604800"} now-ms))))
451+
{"x-codex-primary-used-percent" "100"
452+
"x-codex-primary-reset-at" (str (+ (quot now-ms 1000) 3600))
453+
"x-codex-secondary-used-percent" "100"
454+
"x-codex-secondary-reset-at" (str (+ (quot now-ms 1000) 604800))}
455+
now-ms))))
422456

423-
(testing "past resets, malformed or absent headers return nil"
457+
(testing "past resets, malformed or absent reset data return nil"
424458
(is (nil? (llm-providers.errors/rate-limit-wait {"retry-after" "0"} now-ms)))
425459
(is (nil? (llm-providers.errors/rate-limit-wait {"retry-after" "garbage"} now-ms)))
426460
(is (nil? (llm-providers.errors/rate-limit-wait
427461
{"anthropic-ratelimit-requests-reset" "not-a-date"} now-ms)))
428462
(is (nil? (llm-providers.errors/rate-limit-wait
429463
{"anthropic-ratelimit-requests-reset" (instant-str (- now-ms 30000))} now-ms)))
464+
(is (nil? (llm-providers.errors/rate-limit-wait
465+
nil
466+
"{\"error\":{\"type\":\"rate_limit_exceeded\",\"resets_at\":1000000060}}"
467+
now-ms)))
468+
(is (nil? (llm-providers.errors/rate-limit-wait
469+
nil
470+
{:error {:type "usage_limit_reached"
471+
:resets_at (- (quot now-ms 1000) 60)}}
472+
now-ms)))
473+
(is (nil? (llm-providers.errors/rate-limit-wait nil "not-json" now-ms)))
430474
(is (nil? (llm-providers.errors/rate-limit-wait {} now-ms)))
431475
(is (nil? (llm-providers.errors/rate-limit-wait nil now-ms))))))

0 commit comments

Comments
 (0)