Skip to content

Commit 08ec1d0

Browse files
authored
Merge pull request #531 from itkonen/fix/codex-rate-limit-reset
Fix Codex OAuth rate limit reset handling
2 parents d3e901f + 03d3b36 commit 08ec1d0

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
@@ -7,6 +7,7 @@
77
- Fix missing line break after "Prompt stopped" message when followed by another system message.
88
- Scope restored model, variant, and trust selections to the opened, resumed, or imported chat instead of changing other chats and session defaults.
99
- Return an atomic model, agent, variant, variants, and trust selection snapshot from `chat/open`.
10+
- Fix ChatGPT/Codex OAuth rate-limit retries using the response reset instead of quota snapshot headers; cap waits at 60 seconds by default.
1011

1112
## 0.147.0
1213

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

@@ -507,23 +508,28 @@
507508
default-max-retries)
508509
rl-wait (when (= :rate-limited error-type)
509510
(llm-providers.errors/rate-limit-wait (:headers error-data)
511+
(:body error-data)
510512
(System/currentTimeMillis)))
511-
max-wait-ms (some-> (:rateLimitMaxWaitSeconds provider-config) long (* 1000))
512-
wait-too-long? (boolean (and rl-wait
513-
max-wait-ms
514-
(> (long (:delay-ms rl-wait)) (long max-wait-ms))))]
513+
rate-limit-delay-ms (some-> rl-wait
514+
:delay-ms
515+
long
516+
(+ rate-limit-wait-buffer-ms))
517+
max-wait-ms (* 1000
518+
(long (or (:rateLimitMaxWaitSeconds provider-config)
519+
default-rate-limit-max-wait-seconds)))
520+
wait-too-long? (boolean (and rate-limit-delay-ms
521+
(> rate-limit-delay-ms max-wait-ms)))]
515522
(if (and (contains? #{:rate-limited :overloaded :retryable-custom :premature-stop} error-type)
516523
(< attempt max-retries)
517524
(not wait-too-long?)
518525
(not @first-response-received*)
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)
@@ -227,23 +228,30 @@
227228
(reduce +)
228229
long)))
229230

230-
(defn ^:private codex-window-reset-ms
231-
"ChatGPT Codex quota headers (`x-codex-<window>-*`): latest reset epoch-ms
232-
among exhausted windows (used-percent >= 100), nil otherwise. Windows with
233-
headroom are ignored on purpose: these headers come on every response with
234-
far-future resets, so only a clearly exhausted window is a wait signal."
235-
[headers now-ms]
236-
(some->> ["primary" "secondary"]
237-
(keep (fn [w]
238-
(let [used (some-> (header-str headers (str "x-codex-" w "-used-percent"))
239-
parse-double)
240-
reset-at (epoch-str->ms (header-str headers (str "x-codex-" w "-reset-at")))
241-
reset-after-s (parse-long-str (header-str headers (str "x-codex-" w "-reset-after-seconds")))]
242-
(when (and used (>= used 100.0))
243-
(or reset-at
244-
(some-> reset-after-s (* 1000) (+ (long now-ms))))))))
245-
seq
246-
(apply max)))
231+
(defn ^:private response-field [m field]
232+
(when (map? m)
233+
(or (get m field)
234+
(get m (keyword field)))))
235+
236+
(defn ^:private codex-usage-limit-reset-ms
237+
"ChatGPT Codex OAuth usage-limit reset from the 429 JSON response body."
238+
[body]
239+
(let [body (cond
240+
(map? body) body
241+
(string? body) (try
242+
(json/parse-string body)
243+
(catch Exception _ nil))
244+
:else nil)
245+
error (response-field body "error")
246+
error-type (response-field error "type")
247+
reset-at (response-field error "resets_at")]
248+
(when (and (= "usage_limit_reached" error-type)
249+
(integer? reset-at))
250+
(* (long reset-at) 1000))))
251+
252+
(defn ^:private future-reset-ms [reset-ms now-ms]
253+
(when (and reset-ms (> (long reset-ms) (long now-ms)))
254+
(long reset-ms)))
247255

248256
(defn ^:private bucket-resets
249257
"All rate-limit reset headers (`*ratelimit*-reset` like Anthropic's or
@@ -264,29 +272,36 @@
264272
headers))
265273

266274
(defn rate-limit-wait
267-
"Computes when a rate-limited request can be retried, from HTTP response
268-
headers (lowercase string keys, as returned by the http client).
275+
"Computes when a rate-limited request can be retried.
276+
277+
The two-argument arity reads HTTP response headers. The three-argument arity
278+
additionally reads the response body used by ChatGPT Codex OAuth errors.
269279
270280
Precedence:
271281
1. `retry-after` (delta seconds or HTTP-date)
272-
2. `anthropic-ratelimit-unified-reset` (epoch seconds; subscription session limits)
273-
3. `x-codex-<window>-reset-at/-reset-after-seconds` (ChatGPT subscription),
274-
only for exhausted windows (used-percent >= 100)
282+
2. ChatGPT Codex `usage_limit_reached` body `error.resets_at` (epoch seconds)
283+
3. `anthropic-ratelimit-unified-reset` (epoch seconds; subscription session limits)
275284
4. rate-limit reset buckets (Anthropic `*ratelimit*-reset` RFC 3339/epoch,
276285
OpenAI `*ratelimit*-reset-<bucket>` Go-style durations): latest exhausted
277286
bucket (remaining = 0), else earliest future reset.
278287
279-
Returns {:delay-ms N :resets-at epoch-ms} or nil when headers give no usable
280-
future reset (callers should fall back to exponential backoff)."
281-
[headers now-ms]
282-
(when (map? headers)
283-
(let [resets-at (or (retry-after-ms headers now-ms)
284-
(epoch-str->ms (header-str headers "anthropic-ratelimit-unified-reset"))
285-
(codex-window-reset-ms headers now-ms)
286-
(let [buckets (filter #(> (long (:reset-ms %)) (long now-ms)) (bucket-resets headers now-ms))
287-
exhausted (filter #(some-> (:remaining %) (<= 0)) buckets)]
288-
(or (some->> (seq exhausted) (map :reset-ms) (apply max))
289-
(some->> (seq buckets) (map :reset-ms) (apply min)))))]
290-
(when (and resets-at (> (long resets-at) (long now-ms)))
291-
{:delay-ms (- (long resets-at) (long now-ms))
292-
:resets-at (long resets-at)}))))
288+
Codex quota-window headers are status snapshots, not retry instructions.
289+
290+
Returns {:delay-ms N :resets-at epoch-ms} or nil when no usable future reset
291+
is present (callers should fall back to exponential backoff)."
292+
([headers now-ms]
293+
(rate-limit-wait headers nil now-ms))
294+
([headers body now-ms]
295+
(let [headers (if (map? headers) headers {})
296+
buckets (filter #(> (long (:reset-ms %)) (long now-ms)) (bucket-resets headers now-ms))
297+
exhausted (filter #(some-> (:remaining %) (<= 0)) buckets)
298+
bucket-reset (or (some->> (seq exhausted) (map :reset-ms) (apply max))
299+
(some->> (seq buckets) (map :reset-ms) (apply min)))
300+
resets-at (some #(future-reset-ms % now-ms)
301+
[(retry-after-ms headers now-ms)
302+
(codex-usage-limit-reset-ms body)
303+
(epoch-str->ms (header-str headers "anthropic-ratelimit-unified-reset"))
304+
bucket-reset])]
305+
(when resets-at
306+
{:delay-ms (- resets-at (long now-ms))
307+
: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
@@ -455,33 +455,77 @@
455455
(llm-providers.errors/rate-limit-wait
456456
{"x-ratelimit-reset" (str (+ now-ms 5000))} now-ms))))
457457

458-
(testing "chatgpt codex exhausted window uses reset-at epoch"
459-
(let [reset-epoch-s (+ (quot now-ms 1000) 1800)]
460-
(is (= {:delay-ms 1800000 :resets-at (* reset-epoch-s 1000)}
458+
(testing "chatgpt codex usage-limit reset accepts raw and decoded response bodies"
459+
(let [reset-epoch-s (+ (quot now-ms 1000) 1800)
460+
expected {:delay-ms 1800000 :resets-at (* reset-epoch-s 1000)}
461+
headers {"x-codex-primary-used-percent" "100"
462+
"x-codex-primary-reset-at" (str (+ reset-epoch-s 3600))}]
463+
(doseq [[label body]
464+
[["raw JSON string"
465+
(str "{\"error\":{\"type\":\"usage_limit_reached\",\"resets_at\":" reset-epoch-s "}}")]
466+
["keyword-keyed decoded map"
467+
{:error {:type "usage_limit_reached" :resets_at reset-epoch-s}}]
468+
["string-keyed decoded map"
469+
{"error" {"type" "usage_limit_reached" "resets_at" reset-epoch-s}}]]]
470+
(is (= expected
471+
(llm-providers.errors/rate-limit-wait headers body now-ms))
472+
label))))
473+
474+
(testing "future retry-after takes precedence over chatgpt codex body reset"
475+
(let [reset-epoch-s (+ (quot now-ms 1000) 1800)
476+
body {:error {:type "usage_limit_reached" :resets_at reset-epoch-s}}]
477+
(is (= {:delay-ms 7000 :resets-at (+ now-ms 7000)}
461478
(llm-providers.errors/rate-limit-wait
462-
{"x-codex-primary-used-percent" "100"
463-
"x-codex-primary-reset-at" (str reset-epoch-s)
464-
"x-codex-secondary-used-percent" "0"} now-ms)))))
465-
466-
(testing "chatgpt codex exhausted window falls back to reset-after-seconds"
467-
(is (= {:delay-ms 3600000 :resets-at (+ now-ms 3600000)}
468-
(llm-providers.errors/rate-limit-wait
469-
{"x-codex-primary-used-percent" "100"
470-
"x-codex-primary-reset-at" ""
471-
"x-codex-primary-reset-after-seconds" "3600"} now-ms))))
479+
{"retry-after" "7"}
480+
body
481+
now-ms)))))
482+
483+
(testing "expired retry-after falls through to a future chatgpt codex body reset"
484+
(let [reset-epoch-s (+ (quot now-ms 1000) 1800)
485+
body {:error {:type "usage_limit_reached" :resets_at reset-epoch-s}}
486+
expired-http-date (.format java.time.format.DateTimeFormatter/RFC_1123_DATE_TIME
487+
(.atZone (java.time.Instant/ofEpochMilli (- now-ms 60000))
488+
(java.time.ZoneId/of "GMT")))]
489+
(doseq [retry-after ["0" expired-http-date]]
490+
(is (= {:delay-ms 1800000 :resets-at (* reset-epoch-s 1000)}
491+
(llm-providers.errors/rate-limit-wait
492+
{"retry-after" retry-after}
493+
body
494+
now-ms))))))
495+
496+
(testing "past chatgpt codex body reset falls through to a future lower-priority reset"
497+
(let [past-reset-epoch-s (- (quot now-ms 1000) 60)
498+
future-reset-epoch-s (+ (quot now-ms 1000) 30)]
499+
(is (= {:delay-ms 30000 :resets-at (* future-reset-epoch-s 1000)}
500+
(llm-providers.errors/rate-limit-wait
501+
{"anthropic-ratelimit-unified-reset" (str future-reset-epoch-s)}
502+
{:error {:type "usage_limit_reached" :resets_at past-reset-epoch-s}}
503+
now-ms)))))
472504

473-
(testing "chatgpt codex windows with headroom are not a wait signal"
505+
(testing "chatgpt codex quota-window headers are not retry instructions"
474506
(is (nil? (llm-providers.errors/rate-limit-wait
475-
{"x-codex-primary-used-percent" "0"
476-
"x-codex-primary-reset-at" (str (+ (quot now-ms 1000) 604800))
477-
"x-codex-primary-reset-after-seconds" "604800"} now-ms))))
507+
{"x-codex-primary-used-percent" "100"
508+
"x-codex-primary-reset-at" (str (+ (quot now-ms 1000) 3600))
509+
"x-codex-secondary-used-percent" "100"
510+
"x-codex-secondary-reset-at" (str (+ (quot now-ms 1000) 604800))}
511+
now-ms))))
478512

479-
(testing "past resets, malformed or absent headers return nil"
513+
(testing "past resets, malformed or absent reset data return nil"
480514
(is (nil? (llm-providers.errors/rate-limit-wait {"retry-after" "0"} now-ms)))
481515
(is (nil? (llm-providers.errors/rate-limit-wait {"retry-after" "garbage"} now-ms)))
482516
(is (nil? (llm-providers.errors/rate-limit-wait
483517
{"anthropic-ratelimit-requests-reset" "not-a-date"} now-ms)))
484518
(is (nil? (llm-providers.errors/rate-limit-wait
485519
{"anthropic-ratelimit-requests-reset" (instant-str (- now-ms 30000))} now-ms)))
520+
(is (nil? (llm-providers.errors/rate-limit-wait
521+
nil
522+
"{\"error\":{\"type\":\"rate_limit_exceeded\",\"resets_at\":1000000060}}"
523+
now-ms)))
524+
(is (nil? (llm-providers.errors/rate-limit-wait
525+
nil
526+
{:error {:type "usage_limit_reached"
527+
:resets_at (- (quot now-ms 1000) 60)}}
528+
now-ms)))
529+
(is (nil? (llm-providers.errors/rate-limit-wait nil "not-json" now-ms)))
486530
(is (nil? (llm-providers.errors/rate-limit-wait {} now-ms)))
487531
(is (nil? (llm-providers.errors/rate-limit-wait nil now-ms))))))

0 commit comments

Comments
 (0)