Repository navigation
fix(sse): lock the model on a 429 whose RetryInfo wait exceeds the queue budget - #16525
Merged
diegosouzapw merged 15 commits intoOct 18, 2026
Conversation
…udget A 429 body that names its own retry (Gemini RetryInfo.retryDelay) froze that key's limiter for the whole wait. Gemini answers an exhausted daily quota with retryDelay "86399s", and nothing else recorded it: no model lockout, no cooldown. The combo kept routing back to the frozen key, every request sat out the full queue budget (maxWaitMs, 30s by default) and failed as a local 503 "Request exceeded queue budget"; ten such keys burned a 300s combo timeout without one upstream call. - updateFromResponseBody: on a 429, a body retry longer than the connection's maxWaitMs now locks that model on the connection (capped at 24h) and leaves the limiter unpaused. The combo pre-dispatch gate and account selection already skip on isModelLocked(). Shorter waits keep pacing the limiter as before. - withRateLimit: a limiter pause recorded on a freshly built limiter that outlasts the caller's remaining queue time now rejects at once with the queue-timeout code instead of waiting that time out. Any later reservoir change forgets the pause, so a relaxed limiter never turns calls away. - updateFromHeaders: the 429 log no longer claims "pausing for 60s"; the header path resets the limiter and sets no pause. The new logic lives in open-sse/services/rateLimitManager/bodyRetryPause.ts so rateLimitManager.ts stays under its file-size ceiling. Tests: tests/unit/rate-limit-long-body-retry-lockout.test.ts (4 cases fail on the base, 3 guards pass on both).
Narrow the long body-retry lockout: - Only a Google RetryInfo.retryDelay converts to a model lockout. Prose "retry after Ns" hints stay a limiter pause, so they never bypass the lockout maxCooldownMs cap the way authoritative hints may. - Only a limiter already scoped to the model (gemini, github, antigravity family, codex scope) converts. A connection-wide limiter keeps pausing the whole connection: locking one model there would let the connection's other models through the paused key. - Any update to the reservoir or its refresh schedule forgets a recorded pause, not only a reservoir reset, so a later refresh change never leaves a stale fail-fast behind. - The lockout log moves into bodyRetryPause (logger injected, as the wedge watchdog already does) to keep rateLimitManager.ts within its frozen size. Tests: the timer-free decisions move to rate-limit-body-retry-pause.test.ts with an injected clock, and the end-to-end file gains the prose and connection-wide cases with wider timing margins (5 s budget, 2.5 s "at once").
Three gaps in the long body-retry lockout: - The lock was written under the resolved model only, while the combo pre-dispatch gate and account selection check the name the client asked for. With a custom alias the gate missed the lock and, now that the limiter is not paused, the request went upstream again. chatCore passes its pre-alias model to updateFromResponseBody, which locks it with the resolved one, as chatCore's own quota path already does. - Any details[].retryDelay counted as RetryInfo. The lockout now reads the wait through retryInfoDetailsMs, the typed reader behind the google_rpc_retry_info provenance (24h cap included), so an untyped or differently typed retryDelay stays a pause. - The recorded pause end was taken after the limiter was built, while Bottleneck starts the refresh clock at construction, so a slow build overstated the pause and the fail-fast could turn away a call the refill would serve. The time is now taken before the build. The test-quiet log helpers move to rateLimitManager/log.ts so bodyRetryPause logs directly and rateLimitManager.ts stays within its frozen size with the new parameter.
…amily The alias lock went through lockModel, whose keys group antigravity models by quota family and codex models by scope. An alias that crosses them locked its own family for the whole RetryInfo wait: a gemini-2.5-pro alias for claude-sonnet-4-5 took gemini-2.5-flash down on that connection, and a gpt-5.5 alias for the spark model took gpt-5.4 down. The requested name is now locked with lockExactModel, the exact key isModelLocked already checks, so the combo gate and account selection still skip the alias and nothing else is locked. The anchor guard now measures the refill from the limiter's construction instead of requiring a fixed second of waiting after the stall, which a slow runner could use up without anything being wrong.
…relock After a 429, src/sse/services/auth.ts records its own model lockout at the API-key base cooldown (3 s). That write goes through lockModel, which keeps the longer lock, so the RetryInfo lock survives it. On the base only the 3 s lock exists, which is the stall cheaper-inference#16442 reports: the router picks the model again after 3 s and every request waits out the queue budget. The new case replays that sequence and fails on the base with "the lockout shrank to 3000ms".
…ts family Antigravity's limiter is per quota family, so the scoped model reached lockModel, which keys antigravity by quota family too: one exhausted Claude model on a connection locked the whole Claude family there for up to a day. Antigravity's other quota locks are exact (lockModelIfPerModelQuota, the core-owned quota state), because a confirmed exhaustion of one model must not lock its family, so the RetryInfo lock now uses lockExactModel on antigravity and agy too. The docs, the changelog fragment and the test messages now say that the combo pre-dispatch gate sees the lock only for targets pinned to the connection; account selection covers the rest. The end-to-end test clears RATE_LIMIT_MAX_WAIT_MS, which moves the default 30 s queue budget its cases assume.
The 429 log line this branch rewrote said "no retry-after header" whenever the header did not parse to a positive wait, including a literal "Retry-After: 0" or a value it could not read. It now says so only when the header is absent, and otherwise prints the header as sent.
The fragment was named 429-long-body-retry-lockout.md, which reads as PR cheaper-inference#429 next to its <PR>-<slug> siblings. Fragments opened before their PR number exists use the 0000- prefix (25 of them on this branch, contributor PRs included), so this one does too.
Passing the requested model to updateFromResponseBody added a line to open-sse/handlers/chatCore.ts. On the current release/v3.8.52 tip the file sits exactly at its frozen 3938-line ceiling (cheaper-inference#16456 added three lines), so the merge measured 3939 and check-file-size failed. The sibling updateFromHeaders call now fits on its if line, like the single-line `if (toolSummary) log?.debug?.(...)` guard in the same file. The branch drops to 3934 lines and the merge to 3937, without touching file-size-baseline.json.
This was referenced Oct 13, 2026
The note this branch added to the 429 log line (the parsed wait, the header as sent, or its absence) pushed updateFromHeaders past the ceilings that check:complexity-ratchets enforces on a PR's changed files: cyclomatic 16 and cognitive 18, against 15. Against the release tip, rateLimitManager.ts went from 3 to 4 complexity findings and from 2 to 3 cognitive-complexity ones, which the new-code mode reports as a regression. The note is now describeRetryAfter() in rateLimitManager/headers.ts, the zero-import leaf that already holds parseResetTime, and the log line reads the same. A unit test pins its cases: a parsed wait, a zero or unparsable value as sent, and no header. rateLimitManager.ts drops from 1322 to 1319 lines by the check:file-size count.
check:mutation-test-coverage --strict fails a PR when a unit test that imports a mutated module is missing from stryker.conf.json tap.testFiles, because its mutant kills would not count. Both new test files import accountFallback.ts (isModelLocked, getModelLockoutInfo), and this PR's Quality Gates run listed them. They now sit in alphabetical order in the rate-limit-* block, as cheaper-inference#15486 did for its own tests. The gate's other entries on the release tip (12 tests across 5 modules) predate this branch.
Contributor
Author
|
CI on
The ten failing unit test files, on both sides
#16528 (@skygunner,
|
…ference#16528 RetryInfo lockout cheaper-inference#16528 relays a details-only RetryInfo window into the model lockout, but updateFromResponseBody still emptied the per-model reservoir for that window, so a call reaching the key before it ended (after the resilience reset clears the lockout) sat out the whole queue budget. The new case fails on the tip without this PR and passes with it. Changelog and RESILIENCE_GUIDE now say what this adds on top of cheaper-inference#16528.
diegosouzapw
merged commit Oct 18, 2026
c76ee07
into
cheaper-inference:release/v3.8.52
6 of 8 checks passed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Addresses the queue stall reported in #16442, for every
RetryInfowait longer than the queuebudget: the 1104 s and 3009 s waits there, and the
86399sGemini sends for an exhausted dailyquota. The failure-path gaps #16442 also describes stay open; see Notes.
Problem
A 429 body that names its own retry froze that key's limiter for the whole wait, while the router
heard about it for 3 s.
updateFromResponseBodyempties the key's reservoir until the stated waitends. The failure path does record a model lockout for the 429, whatever the generic lockout
setting, but, as #16442 traces, only at the API-key base cooldown of 3 s, because it does not read
error.details[].retryDelay. So 3 s later the router picks the frozen key again. Every dispatchsits out
requestQueue.maxWaitMs(30 s by default), fails as a local 503Request exceeded queue budget, and only then falls back. Ten frozen keys ahead of the next workingtarget burned the 300 s combo timeout without one upstream call.
Measured on one production combo on 3.8.51, 2026-10-06 → 2026-10-12:
Combo global timeout (300000ms).gemini-3.6-flashinsidethe combo were local queue 503s (p50 30,085 ms); only 86 came back as upstream 429s.
88 between 31 s and 1 h, and 70 at or under 30 s.
Fix
updateFromResponseBody: a 429 that carries a typed GoogleRetryInfodetail whoseretryDelayis longer than the connection'smaxWaitMsnow locks the model on that connection.This applies when the key's limiter is already scoped to the model (
getLimiterKey: gemini andgithub per model, antigravity per quota family, codex per model scope).
lockModel, which keys those same scopes (getModelLockKey), so it coverswhat the pause covered. The limiter is left unpaused.
exact model (
lockExactModel), like its other quota locks (lockModelIfPerModelQuota, thecore-owned quota state). An exhausted Claude model then does not take the whole Claude family
down on that connection for up to a day.
short lockout (the 3 s in fix(resilience): Gemini 429 RetryInfo (details-only) is dropped before model lockout — 3s lock while the limiter pauses 1104s, requests stall 30s in queue #16442, through
recordModelLockoutFailure), theRetryInfolockstands.
retryInfoDetailsMs, the reader behind thegoogle_rpc_retry_infoprovenance (now exported from
retryAfterJson.ts). That includes its@typecheck and its24 h cap.
account selection (and the combo pre-dispatch gate, for targets pinned to the connection) checks
the requested name.
lockExactModel, whose keyisModelLockedalready checks.Through
lockModel's quota-family keys, an alias that crosses an antigravity family or acodex scope would lock its own family for the whole wait. For example, a
gemini-2.5-proalias for
claude-sonnet-4-5would takegemini-2.5-flashdown on that connection.modelas a new optional argument. Its end-of-requestcall runs on both legs (
applyProviderFailureClassificationin the streaming andnon-streaming handlers).
requestedModelis already theresolved one.
maxWaitMs.retryDelayfields: they arenot authoritative, so they must not turn into a lock that escapes the lockout
maxCooldownMscap.
through a paused key.
maxWaitMs = 0(no queue deadline, fix(resilience): allow maxWaitMs=0 as disable sentinel for execution expiration #12902).withRateLimit: when the limiter that the 429 path just rebuilt is paused, and the pauseoutlasts the caller's remaining queue time, the call is turned away at once. It gets the
queue-timeout code (
RATE_LIMIT_QUEUE_TIMEOUT, 503), with the same classification and combofallback as before, instead of waiting that time out. This covers the prose, untyped and
connection-wide cases above.
updateFromHeaders: the 429 log no longer sayspausing for 60s(the first line of the login fix(resilience): Gemini 429 RetryInfo (details-only) is dropped before model lockout — 3s lock while the limiter pauses 1104s, requests stall 30s in queue #16442). That path evicts the limiter and takes no pause. The log now prints the real
retry-after, the header as sent when it does not parse to a positive wait, or says there wasnone. That note is built by
describeRetryAfterinrateLimitManager/headers.ts, next toparseResetTime. Built inline, it tookupdateFromHeadersto a complexity of 16 (cognitive 18)against the limit of 15 in the repository's ESLint rules, a new finding for
check:complexity-ratchetson a changed file.The new logic lives in
open-sse/services/rateLimitManager/bodyRetryPause.ts. The three test-quietlog helpers move to
rateLimitManager/log.tsunchanged, so the new module logs the same way andrateLimitManager.tsstays within its frozen size: 1312 → 1319 lines by thecheck:file-sizecount, ceiling 1329. In
chatCore.tsthe new argument adds a line, and the siblingupdateFromHeaderscall moves onto itsifline, as the file already does for other one-lineguards: 3935 → 3934 on the branch.
release/v3.8.52now sits exactly at the 3938 ceiling after#16456, and the merge with it measures 3937, so
file-size-baseline.jsonis untouched.Why a model lockout
It is the signal both routers already read, independent of the opt-in generic lockout setting:
open-sse/services/combo/executeTargetGates.ts,isModelLocked)for targets pinned to a connection;
src/sse/services/auth.ts,isModelLocked) for every request.The lock calls themselves (
lockModel,lockExactModel) are unconditional.It has the scope the pause had, or a narrower one on antigravity. Other models on the same key
keep serving where the quota is per model, and so do other keys.
When every connection for the model is locked, account selection answers
allRateLimitedwitha model-scoped cooldown ("getProviderCredentials reports allRateLimited when every account is
model-locked" in
tests/unit/sse-auth.test.tspins this), and the cooldown-aware retry does notsit out a wait longer than
maxRetryWaitMs. The request, or the combo target, fails at onceinstead of costing 30 s and a 503 per key.
It is visible (
GET /api/rate-limitslists model lockouts) and operators can clear it.POST /api/resilience/resetclears model lockouts, and a successful request on the same nameclears its lock (
clearModelLockinsrc/sse/handlers/chat.ts), like every other model lock.The frozen reservoir showed up nowhere and no reset touched it. Only a rebuilt limiter ended it
early: a restart, a settings or override change, a protection toggle, or a later 429 with
headers on the same limiter, which the frozen queue itself kept from happening. That is also why
the lock does not come with a pause: a reset would clear the lock and leave an invisible pause
behind.
Why the fail-fast cannot turn away a request the queue would serve
end is taken before the build, because Bottleneck starts the refresh clock in the datastore
constructor (
_lastReservoirRefresh) and the heartbeat patch keeps that anchor. So the recordedend never runs past the real refill, even when the build is slow. On an older limiter the refill
can come sooner, so nothing is recorded there.
header learning, settings and override changes, and a relaxed limiter never fails calls fast.
by later headers no longer fails calls fast. A limiter build that stalls for 2.5 s does not stop a
call with 3 s of budget from being served by the refill 1.5 s later.
Tests
tests/unit/rate-limit-long-body-retry-lockout.test.ts(new, end to end through the manager, 13cases; it clears
RATE_LIMIT_MAX_WAIT_MSat load, because that variable moves the default 30 squeue budget the cases assume):
7f3cbb046bRetryInfo(86399s) locks the model on that connection, leaves the key's other models free, and the next call on the key is served at onceisModelLockedis false)999999s)RetryInfofits the 30 s queue budget, so the limiter pauses; a caller with 5 s left is turned away at onceretryDelayoutside a typedRetryInfodetail never becomes a lock; its pause turns callers away at onceRetryInfolocks nothing and keeps the pause, so another model on the connection is turned away at once1104sRetryInfo, then the failure path's own 3 s lockout (recordModelLockoutFailure); the lock still lasts theRetryInfowaitThe base column is this file run against a checkout of
7f3cbb046b(9 fail, 4 pass); with this PRall 13 pass. The four guards pass on both sides on purpose: they pin the behaviour the fix must not
break.
tests/unit/rate-limit-body-retry-pause.test.ts(new, 7 timer-free cases with an injected clock):maxWaitMs = 0, prose, untyped andother-typed
retryDelay, and the 24 h cap;across scopes leave their own family serving (through
lockModelthis case fails);serving (through
lockModelthis case fails);retryInfoDelayMs.It imports the new module, so it cannot run on the base.
tests/unit/ratelimitmanager-headers-split.test.ts(one new case) pinsdescribeRetryAfter: aparsed wait, a zero or unparsable header as sent, and no header. This case was not run on this
machine, CI runs it; its six inputs were checked by calling the function directly.
Both new test files import
accountFallback.ts, a mutated module, so they are listed instryker.conf.jsontap.testFiles, ascheck:mutation-test-coverage --strictrequires. Thegate's other entries on
9ecddd15a1(12 tests across 5 modules) predate this branch.Also run locally (Windows 11, Node 24.19.0):
npm run typecheck:core: clean on the branch, on its merge with47688ba5ea, and on its mergewith
9ecddd15a1, the currentrelease/v3.8.52tip.config/quality/eslint-suppressions.json, and Prettier, on the changed sources andtests: clean. The pre-commit hooks passed on every commit (lint-staged, docs-sync,
check:any-budget:t11,check:tracked-artifacts,check:ai-attribution).node scripts/check/check-cycles.mjs --ratchet: 14 cycles, at the ceiling of 14.npm run check:file-size, on the branch and on its merges with47688ba5eaand9ecddd15a1:the only violation is
open-sse/services/combo/executeTargetAttempt.ts(1288 > 1284). refactor(combo): route strategy-specific branching through a typed strategy registry (rail 3.8.52 R0.4a) #16206grew it from 1284 without a baseline entry; this PR does not touch it. On the merge with
9ecddd15a1,chatCore.tsmeasures 3937 (ceiling 3938) andrateLimitManager.ts1319(ceiling 1329).
npm run check:docs-all: pass.47688ba5ea, the 28 neighbouring unit files (list below) pluscombo-model-lock-pre-dispatch-wait-16319,gemini-deprecated-model-lockoutandcombo-priority-all-circuit-open: 315 of 317 pass. One failure israte-limit-learned-cap-13594,whose
after()hook cannot delete its temp SQLite directory on Windows (EPERM), the same on thebase. The other is a wall-clock setup step in
rate-limit-manager, described below.de911917aa(the head before thedescribeRetryAftercommit)with
9ecddd15a1, which includes fix(sse): honor Google RPC ErrorInfo quota-reset metadata as an upstream retry hint #16529: 326 of 327 pass, including the cases fix(sse): honor Google RPC ErrorInfo quota-reset metadata as an upstream retry hint #16529 added toaccount-fallback-retry-after-jsonandclassify429. The one failure is again theafter()hook of
rate-limit-learned-cap-13594(EPERM).rate-limit-managerpassed all 19 tests, thenagain kept its process alive until a 300 s cap.
rate-limit-manager-queue-bound, "timeout before dispatch (<80ms not 200ms)", is a wall-clockbound: a 30 ms budget must reject in under 80 ms. On this busy Windows machine it went over in
three of this branch's long runs (120, 146 and 131 ms) and passed in the others, including the
final one. To tell the diff from the load, a replica of that case (same imports and hooks,
printing the measured time) ran 40 times on each side, alternating base and branch, each run in
a fresh process: median 40 vs 39 ms, p90 47 vs 49 ms, and the base went over 80 ms once
(144 ms), the branch never (max 71 ms). This change adds only a synchronous
WeakMaplookup tothat path.
combo-model-lock-pre-dispatch-wait-16319(from fix(resilience): wait out a short model lock instead of failing a combo instantly (#16319) #16360) bounds its quota-lock case at 1000 ms ofwall clock. It went over once (3629 ms) inside a 14-minute run of ten files on the merge with
e2d5747872. Alone, alternating the merge with47688ba5eaand that tip in fresh processes,three runs a side, all six passed: 37–151 ms on the merge, 53–232 ms on the tip.
rate-limit-manager, "idle-capacity watchdog preserves a real Bottleneck minTime wait", pollsevery 5 ms, for at most 1 s, for the job to sit in Bottleneck's 100 ms
minTimewait. In the20-minute run above it never saw it (
Bottleneck did not place the minTime-delayed job in RUNNING). It passed in this branch's four earlier long runs, and alone on both sides (1145 ms onthe merge, 666 ms on the tip). This change adds no timer, and the only line it adds to
withRateLimitreturns at once when no body pause is recorded.rate-limit-manager.test.tskeeps its process alive for minutes afterall 19 tests pass, with or without this branch. Run alone, it hit a 240 s cap on the tip
47688ba5eaand on the merge with it, and a 200 s cap on the merge withe2d5747872.The 28 unit files
antigravity-429-quota-cooldown,bug-7940-gemini-retrydelay,chat-rate-limit-body-lock,classify429,combo-model-lockout-honors-reset-1308,provider-execution-pipeline,rate-limit-enhanced,rate-limit-learned-cap-13594,rate-limit-long-body-retry-lockout,rate-limit-body-retry-pause,rate-limit-manager-queue-bound,rate-limit-manager,rate-limit-overrides-restart,rate-limit-queue-context,rate-limit-remaining-budget,rateLimitManager-idle-eviction,rateLimitManager-update-sequencing,ratelimit-reservoir-refresh,ratelimitmanager-headers-split,muse-code-oauth,upstream-status-restatement,account-fallback-retry-after-json,chatcore-stream-error-result,chatcore-quota-share-consumption,chatcore-streaming-quota-share,combo-quota-exhaustion-only-fallback,repro-7387-sticky-quota-exhausted,rate-limit-abandoned-queued-job(all undertests/unit/,--test-concurrency=1).Notes
lock in a combo.
retry_afterit adds to the terminal response, apply only to locks whosereason is on its transient allow-list (
isTransientComboCooldownReason:rate_limit,rate_limited,transient,overloaded,server_error,circuit_open).rate_limit_exceeded, the reasonparseRetryAfterFromBodyreports. Whenthe failure path's 3 s
rate_limitedlock follows it, the reason stays: both lock functions keepthe longer lock, and
getModelLockoutInforeports the one with the most time left. So fix(resilience): wait out a short model lock instead of failing a combo instantly (#16319) #16360neither waits out nor advertises a
RetryInfolock, whatever its length.ALL_TARGETS_SKIPPED,as it does for a quota lock. Whether a
RetryInfolock inside fix(resilience): wait out a short model lock instead of failing a combo instantly (#16319) #16360's 90 s ceiling(
comboCooldownWait.maxWaitMs) should be waited out instead is a follow-up.docs/architecture/RESILIENCE_GUIDE.md, and fix(chat): expose route metadata on pre-dispatch errors #16456 anotherpart of
chatCore.ts. Neither overlaps the hunks here.2h7m23s) reach both readers used here. The limiter pause(
parseRetryAfterFromBody) and the lock (retryInfoDetailsMs) both parse throughparseDelayString, so they see the same value. A typedRetryInfowithretryDelay: "2h7m23s", which the base read as 2 s, now locks the model for 2 h 7 min 23 s.ErrorInfoquota-reset metadata does not reach this path, becauseparseRetryAfterFromBodyreadsdetails[]only forretryDelayfields. AnErrorInfo-only429 therefore takes neither the pause nor the lock from that detail; a prose hint in its message
is handled as before. When a body carries both details, the lock lasts the
RetryInfowait.9ecddd15a1checked the compound andErrorInfocases(4 of 4 pass). It is not part of this PR, because the branch predates fix(sse): honor Google RPC ErrorInfo quota-reset metadata as an upstream retry hint #16529.
RetryInfowaits past the queue budget. For a typedRetryInfo, it also covers its point (b): a limiter pause longer than the model lockout shouldcount for target eligibility. Still open there:
parseUpstreamError()andcheckFallbackError()do not readerror.details[].retryDelay.The failure path's own lockout therefore stays at the base cooldown for a details-only
RetryInfo, and its log line still prints that base (3 s) next to this PR's🔒line. Pastthe queue budget, the lock above covers the key; inside it, the limiter paces the key.
checkFallbackError()returns before the operatorproviderErrorRuleslookup, so no rule can lengthen a Gemini 429 lockout.
503 comes at once when the recorded pause outlasts the caller's queue time, instead of after
maxWaitMs.gemini-3.6-flashis still missing fromopen-sse/config/geminiRateLimits.json, so the RPDclassifier cannot tell its daily quota from a per-minute limit.
withRateLimitonly as an abort signal, so the fail-fast comparesagainst the queue budget, not against the time left in the combo.
RetryInfoon a non-429 status keeps the old pause.parseRetryAfterFromBodyfinds a positive wait, as thepause always has. A body whose first
retryDelayis invalid or zero therefore hides a latertyped
RetryInfofrom both, on the base as here. Google's 429 bodies carry a singleRetryInfo, so this stays as it was to keep the change to the reported defect.target.connectionId || "", so an unpinned target relies on accountselection to skip the locked connection. This is existing behaviour for every model lock.
parseRetryAfterFromBodyhas no ceiling, while the typed reader caps at 24 h. A prose,untyped or connection-wide
retryDelaylonger than 24 h therefore still pauses the limiter forits full value, as on the base.
modelin chatCore before dispatch, so only thedegraded model is locked, not the name the client sent. The next request on that name reaches
the upstream and gets a 429 back, instead of waiting out the queue as on the base.
changelog.d/fixes/0000-429-long-body-retry-lockout.md, with the0000-prefix the other fragments opened before their PR number use.