-
Notifications
You must be signed in to change notification settings - Fork 1.1k
fix(codex): promote a healthy detour instead of releasing it, and honour Retry-After (#4546) #4616
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2946,6 +2946,32 @@ export function resolveCodexAccountForThreadDetailed( | |
| // A model-only exclusion does not invalidate the shared task binding. Health, | ||
| // generation, pause, cooldown, and failure evidence still retire it normally. | ||
| if (!modelScopedSelection || !healthyForSharedAffinity) { | ||
| // A hold that outlived its window is not the same as a conversation with nowhere to go. | ||
| // If the account that has actually been serving this thread is still healthy, promote it | ||
| // instead of deleting the entry and re-picking cold: releasing here threw away the one | ||
| // piece of evidence the request had -- that B works -- and handed the thread back to a | ||
| // fresh strategy choice, which is the cold-prefix cost #4546 is about. A timer expiring | ||
| // restores the right to re-decide; it is not itself a recovery. | ||
| const expiredDetour = entry.transientDetourAccountId; | ||
| if ( | ||
| isTransientHoldExpired(entry, now) | ||
| && generationLive | ||
| && !quotaRefused | ||
| && expiredDetour !== undefined | ||
| && expiredDetour !== entry.accountId | ||
| && isCodexAccountSelectable(config, expiredDetour, now, quotaScope, selectionOptions) | ||
| && !hasUnrecoveredCodexQuotaRefusal(expiredDetour, quotaScope) | ||
| && !shouldFailover(config, expiredDetour, now) | ||
| && !isCodexAccountSoftAvoided(expiredDetour, now) | ||
| ) { | ||
| if (!isIndependentCodexQuotaScope(quotaScope)) promoteActiveCodexAccount(config, expiredDetour); | ||
| bindThreadAffinity(threadId, expiredDetour, now, quotaScope); | ||
| return { | ||
| status: "selected", | ||
| accountId: expiredDetour, | ||
| affinity: { move: "rebound", reason: "transient_hold_expired" }, | ||
|
Comment on lines
+2967
to
+2972
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an ordinary binding's hold has expired and its recorded detour is healthy, this branch now resolves to the detour, but AGENTS.md reference: src/AGENTS.md:L24-L24 Useful? React with 👍 / 👎. |
||
| }; | ||
| } | ||
| releaseReason = !generationLive | ||
| ? "generation" | ||
| : quotaRefused | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -133,8 +133,19 @@ export interface RetryBackoffOptions { | |
| baseDelayMs: number; | ||
| maxDelayMs: number; | ||
| headers?: Headers; | ||
| /** | ||
| * Treat a provider's `Retry-After` as the earliest legal send rather than something the | ||
| * local maximum may shorten. Opt-in per caller so the change lands on the transient path | ||
| * first instead of silently lengthening every adapter's backoff. | ||
| */ | ||
| retryAfterIsLowerBound?: boolean; | ||
| /** Hard ceiling for an honoured `Retry-After`, so an hour-long wait cannot park a request. */ | ||
| retryAfterCeilingMs?: number; | ||
| } | ||
|
|
||
| /** One minute, matching the same-target 429 ceiling the key-failover path already uses. */ | ||
| export const RETRY_AFTER_CEILING_MS = 60_000; | ||
|
|
||
| export function abortError(signal?: AbortSignal): unknown { | ||
| return signal?.reason ?? new DOMException("The operation was aborted", "AbortError"); | ||
| } | ||
|
|
@@ -284,9 +295,20 @@ function retryAfterDelayMs(headers: Headers): number | undefined { | |
|
|
||
| export function retryBackoffDelayMs(attempt: number, opts: RetryBackoffOptions): number { | ||
| const retryAfter = opts.headers ? retryAfterDelayMs(opts.headers) : undefined; | ||
| if (retryAfter !== undefined) return Math.min(retryAfter, opts.maxDelayMs); | ||
| const exp = Math.min(opts.baseDelayMs * (2 ** attempt), opts.maxDelayMs); | ||
| return Math.floor(exp * (0.8 + Math.random() * 0.4)); | ||
| const jittered = Math.floor(exp * (0.8 + Math.random() * 0.4)); | ||
| if (retryAfter === undefined) return jittered; | ||
| if (opts.retryAfterIsLowerBound !== true) { | ||
| // Historical behaviour, still the default for every caller that has not opted in. | ||
| return Math.min(retryAfter, opts.maxDelayMs); | ||
| } | ||
| // A provider that names a wait is stating when it will serve again; sending earlier is a | ||
| // request we already know will be refused, and refusing it twice is the retry storm the | ||
| // header exists to prevent. The local maximum bounds our OWN exponential backoff and has no | ||
| // business shortening someone else's instruction. The ceiling is separate: it stops an | ||
| // hour-long Retry-After from parking a request forever. | ||
| const ceiling = opts.retryAfterCeilingMs ?? RETRY_AFTER_CEILING_MS; | ||
| return Math.min(Math.max(retryAfter, jittered), ceiling); | ||
|
Comment on lines
+305
to
+311
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The commit changes shared account routing under AGENTS.md reference: src/AGENTS.md:L10-L11 Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| export function cancelResponseBodyBestEffort(res: Response): void { | ||
|
|
@@ -504,6 +526,7 @@ export async function fetchWithTransientRetry( | |
| baseDelayMs: TRANSIENT_RETRY_BASE_DELAY_MS, | ||
| maxDelayMs: TRANSIENT_RETRY_MAX_DELAY_MS, | ||
| headers: res.headers, | ||
| retryAfterIsLowerBound: true, | ||
| }); | ||
| cancelResponseBodyBestEffort(res); | ||
| // Throws on abort (see sleepWithAbort): the rejection propagates, and the body we just | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This promotion is reachable only for the ordinary affinity entry. If a model-scoped affinity account fails and detours to a healthy model-eligible sibling, the earlier
detourEntrybranch still deletes that model affinity when the hold expires before execution can reach this block. With another eligible sibling, the subsequent cold strategy selection may move the conversation away from the account that served throughout the hold, recreating the cache-loss behavior this change fixes. Apply equivalent promotion to model-detour affinities and cover that expiry path.AGENTS.md reference: src/AGENTS.md:L24-L24
Useful? React with 👍 / 👎.