Skip to content
12 changes: 11 additions & 1 deletion docs/reference/error-contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,11 +120,21 @@ The default-on localhost Responses proxy returns JSON error payloads with a stab
| `runtime_rotation_proxy_unauthorized` | `401` | Local request did not include the per-process proxy client key |
| `runtime_rotation_proxy_payload_too_large` | `413` | Request body exceeded the proxy safety cap |
| `codex_runtime_rotation_pool_exhausted` | `429` or `503` | No managed account can currently service the runtime request |
| `codex_pinned_account_unavailable` | `503` | A manual pin is set (via `codex-multi-auth switch`) but the pinned account is rate-limited, cooling down, disabled, or blocked by policy. Run `codex-multi-auth status` for details, or `codex-multi-auth unpin` to allow rotation |
| `codex_pinned_account_unavailable` | `503` | A pin is in force — either a manual pin (`codex-multi-auth switch`) or a forced pin set per invocation by the launcher (`--account` / `CODEX_MULTI_AUTH_FORCE_ACCOUNT`) — but the pinned account is rate-limited, cooling down, disabled, or blocked by policy. The remedy depends on `pin_source`: a manual pin clears with `codex-multi-auth unpin`, a forced pin does not and needs a relaunch |
| `codex_runtime_rotation_proxy_error` | `500` | Proxy failed before forwarding the request |

Pool exhaustion includes a `reason`, `retry_after_ms`, and a hint to run `codex-multi-auth rotation status`. Pinned-account-unavailable responses include a `pinnedAccountIndex` field identifying the pinned account, a structured `reason` field carrying the runtime skip reason (for example `rate-limited`, `cooling-down:auth-failure`, `circuit-open`, `disabled`, `workspace-disabled`, `policy-blocked`, `missing`, `already-attempted`) or `null` when no reason was recorded, and an `account_skip_reasons` map keyed by account index that mirrors the pool-exhausted response shape. The human-readable `message` appends the same reason in parentheses when present (see issue #486).

Pinned-account-unavailable responses also carry three recovery fields:

| Field | Type | Meaning |
|-------|------|---------|
| `pin_source` | `"forced"` \| `"manual"` \| `null` | How the pin was set. `manual` came from `codex-multi-auth switch` and clears with `unpin`; `forced` came from this session's launcher (`--account` / `CODEX_MULTI_AUTH_FORCE_ACCOUNT`) and `unpin` will NOT clear it — relaunch to select a different account. The `message` carries the matching remedy |
| `reset_at` | ISO-8601 string \| `null` | When the pinned account next becomes selectable: the latest of its still-active rate-limit record for the request's family and model, any active cooldown, and its circuit breaker's next-admission deadline. `null` when nothing bounds recovery, and deliberately `null` under a permanent blocker (`disabled`, `workspace-disabled`, `policy-blocked`, `missing`, token invalidation), where no timer clears the condition |
| `retry_after_ms` | number \| `null` | The same moment as milliseconds from now. Note this is the *latest* bound for the single pinned account, whereas `codex_runtime_rotation_pool_exhausted` reports the *earliest* recovery across the whole pool |

`retry_after_ms` is advisory; it is not emitted as a `Retry-After` header.

Account policy pause/drain is enforced through runtime policy evaluation and contributes to selection skip reasons such as `policy-blocked`.

---
Expand Down
16 changes: 16 additions & 0 deletions lib/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1294,6 +1294,22 @@ export class AccountManager {
return getCircuitBreaker(getAccountCircuitKey(account)).isAvailable();
}

/**
* When the account's circuit breaker will admit an attempt again, as an
* epoch-ms deadline — null when it already would. Lets the pinned-503
* recovery metadata cover circuit-open skips, whose deadline lives in the
* breaker rather than the persisted account record.
*/
getCircuitRecoveryTime(
account: ManagedAccount,
now = Date.now(),
): number | null {
const waitMs = getCircuitBreaker(
getAccountCircuitKey(account),
).getTimeUntilAvailable(now);
return waitMs > 0 ? now + waitMs : null;
}

incrementAuthFailures(account: ManagedAccount): number {
account.consecutiveAuthFailures =
(account.consecutiveAuthFailures ?? 0) + 1;
Expand Down
58 changes: 57 additions & 1 deletion lib/request/rate-limit-decision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,12 +185,38 @@ export interface PinnedUnavailableErrorBody {
code: "codex_pinned_account_unavailable";
pinnedAccountIndex: number | null;
reason: string | null;
/** How the pin was set; forced pins are not cleared by `unpin`. */
pin_source: "forced" | "manual" | null;
/** When the blocking record ends, when the skip reason is time-bounded. */
reset_at: string | null;
retry_after_ms: number | null;
account_skip_reasons: Record<string, string>;
}

/**
* Largest epoch-ms value `new Date(...).toISOString()` accepts; anything beyond
* throws RangeError (ECMAScript time-value limit, ±100,000,000 days).
*/
const MAX_ECMASCRIPT_TIME_VALUE = 8_640_000_000_000_000;

export interface PinnedUnavailableContext {
/**
* "forced" when the pin came from the wrapper's forced-account mode
* (`--account` / CODEX_MULTI_AUTH_FORCE_ACCOUNT, which the wrapper resolves
* into the internal CODEX_MULTI_AUTH_FORCE_ACCOUNT_INDEX), "manual" when it
* came from `switch`. `unpin` clears only the manual kind, so the remedy
* line must not suggest it for a forced pin.
*/
pinSource?: "forced" | "manual" | null;
/** Epoch ms when the blocking record ends (rate limit or cooldown). */
resetAtMs?: number | null;
now?: number;
}

export function buildPinnedUnavailableErrorBody(
pinnedIndex: number | null | undefined,
accountSkipReasons: ReadonlyMap<number, string>,
context?: PinnedUnavailableContext,
): PinnedUnavailableErrorBody {
const normalizedPinnedIndex =
typeof pinnedIndex === "number" ? pinnedIndex : null;
Expand All @@ -205,11 +231,41 @@ export function buildPinnedUnavailableErrorBody(
normalizedPinnedIndex === null
? "The pinned account"
: `Pinned account ${normalizedPinnedIndex + 1}`;
const pinSource = context?.pinSource ?? null;
// Upper bound as well as lower: resetAtMs comes from persisted account state
// (rateLimitResetTimes, coolingDownUntil), and markAccountCoolingDown clamps
// only the low side while nothing re-validates either on load. A finite but
// absurd deadline past the ECMAScript time limit would make toISOString below
// throw a RangeError inside handleRequestInner, collapsing this diagnostic 503
// into a generic 500 that carries no pinnedAccountIndex, reason, or skip map —
// the exact payload this branch exists to deliver. Such a value bounds nothing
// usable anyway, so treat it as "no known recovery" instead.
const resetAtMs =
typeof context?.resetAtMs === "number" &&
Number.isFinite(context.resetAtMs) &&
context.resetAtMs > 0 &&
context.resetAtMs <= MAX_ECMASCRIPT_TIME_VALUE
? context.resetAtMs
: null;
const now = context?.now ?? Date.now();
const retryAfterMs = resetAtMs !== null ? Math.max(0, resetAtMs - now) : null;
const resetAt = resetAtMs !== null ? new Date(resetAtMs).toISOString() : null;
const waitSuffix =
resetAt !== null ? `; the recorded limit resets at ${resetAt}` : "";
// A forced pin belongs to the launching process, not to the persisted pin
// state, so `unpin` would clear nothing — say what actually helps.
const remedy =
pinSource === "forced"
? "the pin was set by this session's launcher, so relaunch to select a different account"
: "run `codex-multi-auth status` for details, or `codex-multi-auth unpin` to allow rotation";
return {
message: `${accountPhrase} is currently unavailable${reasonSuffix}; run \`codex-multi-auth status\` for details, or \`codex-multi-auth unpin\` to allow rotation.`,
message: `${accountPhrase} is currently unavailable${reasonSuffix}${waitSuffix}; ${remedy}.`,
code: "codex_pinned_account_unavailable",
pinnedAccountIndex: normalizedPinnedIndex,
reason: skipReason,
pin_source: pinSource,
reset_at: resetAt,
retry_after_ms: retryAfterMs,
account_skip_reasons: Object.fromEntries(
[...accountSkipReasons.entries()].map(([index, reason]) => [
String(index),
Expand Down
89 changes: 89 additions & 0 deletions lib/runtime-rotation-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse }
import type { Socket } from "node:net";
import {
AccountManager,
AUTH_INVALIDATION_MARKER,
extractAccountId,
type ManagedAccount,
} from "./accounts.js";
Expand Down Expand Up @@ -72,6 +73,7 @@ import {
responseHeadersForClient,
withTimeout,
} from "./request/stream-failover-runtime.js";
import { getAccountRecoveryTimeForFamily } from "./runtime/account-status.js";
import { chooseAccount } from "./runtime/rotation-account-selection.js";
import {
createRotationProxyState,
Expand Down Expand Up @@ -165,6 +167,19 @@ function toUrlHost(host: string): string {
// failures surfaced only as a last-write-wins status.lastError string. Logs are
// level-gated and carry the per-request correlation id set in handleRequest.
const proxyLog = createLogger("runtime-proxy");

/**
* Pinned skip reasons that no timer clears: the account stays unselectable
* after any concurrent rate-limit record or cooldown expires, so the pinned
* 503 must not advertise that record's expiry as a recovery time.
*/
const PINNED_PERMANENT_SKIP_REASONS: ReadonlySet<string> = new Set([
"missing",
"disabled",
"workspace-disabled",
"policy-blocked",
AUTH_INVALIDATION_MARKER,
]);
const DEFAULT_QUOTA_REMAINING_THRESHOLD = 10;

/** @internal Stable identity key for in-memory quota snapshots across reloads. */
Expand Down Expand Up @@ -1573,9 +1588,83 @@ async function handleRequestInner(
// null reason indicates a forecast/runtime state desync (the pinned
// account was selected but no skip reason was recorded) — see #486.
if (isPinned) {
const pinnedAccount =
typeof pinnedIndex === "number"
? accountManager.getAccountByIndex(pinnedIndex)
: null;
const pinnedSkipReason =
typeof pinnedIndex === "number"
? accountSkipReasons.get(pinnedIndex) ?? null
: null;
// A permanent blocker (disabled, no enabled workspace, invalidated
// auth, policy block, out-of-range pin) outlives every timed record,
// so advertising a record's expiry would invite a retry into another
// 503.
//
// The recorded reason alone is not enough to detect one. It is the
// SELECTION verdict, and this request can make the pin permanently
// unselectable after selection already ran: a workspace-disabled
// 402/403 calls setAccountEnabled(index, false) above and then
// continues, so the next pass records "already-attempted" and the
// disable never surfaces. With a breaker tripped by the same failure
// the 503 would then advertise the circuit's ~30s reset for an
// account no timer will ever re-admit. Re-read the pin's CURRENT
// runtime state so a permanent blocker cannot hide behind the
// verdict; the recorded reason still covers the selection-only
// verdicts ("missing", "policy-blocked") that state cannot express.
const pinnedCurrentSkipReason =
pinnedAccount === null
? null
: accountManager.getManagedAccountRuntimeSkipReason(
pinnedAccount,
context.family,
context.model,
);
const pinnedBlockedPermanently =
(pinnedSkipReason !== null &&
PINNED_PERMANENT_SKIP_REASONS.has(pinnedSkipReason)) ||
(pinnedCurrentSkipReason !== null &&
PINNED_PERMANENT_SKIP_REASONS.has(pinnedCurrentSkipReason));
// Otherwise recovery comes from the pinned account's persisted state, not the
// recorded skip reason: a direct 429/cooldown on the pinned account
// reaches this 503 as "already-attempted" (the retry loop's selection
// verdict) while the record it just wrote is what actually bounds
// recovery — and with several overlapping records the account stays
// skipped until the LAST one expires, so the latest bound is the one
// worth advertising.
const pinnedStateRecoveryAtMs =
pinnedAccount === null
? null
: getAccountRecoveryTimeForFamily(
pinnedAccount,
state.now(),
context.family,
Comment thread
greptile-apps[bot] marked this conversation as resolved.
context.model,
);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
// An open circuit outlives the short failure cooldowns that tripped
// it; its deadline lives in the breaker, not the account record.
const pinnedCircuitRecoveryAtMs =
pinnedAccount === null
? null
: accountManager.getCircuitRecoveryTime(pinnedAccount, state.now());
const pinnedResetAtMs =
pinnedBlockedPermanently ||
(pinnedStateRecoveryAtMs === null && pinnedCircuitRecoveryAtMs === null)
? null
: Math.max(
pinnedStateRecoveryAtMs ?? 0,
pinnedCircuitRecoveryAtMs ?? 0,
);
const errorBody = buildPinnedUnavailableErrorBody(
pinnedIndex,
accountSkipReasons,
{
// typeof check so a forced index of 0 still reads as forced.
pinSource:
typeof state.forcedAccountIndex === "number" ? "forced" : "manual",
resetAtMs: pinnedResetAtMs,
now: state.now(),
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);
if (errorBody.reason === null) {
state.status.lastError = `pinned-503 missing skip reason (pinnedIndex=${pinnedIndex})`;
Expand Down
37 changes: 37 additions & 0 deletions lib/runtime/account-status.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getQuotaKey } from "../accounts/rate-limits.js";
import type { ModelFamily } from "../prompts/codex.js";

export function resolveActiveIndex(
Expand Down Expand Up @@ -38,6 +39,42 @@ export function getRateLimitResetTimeForFamily(
return minReset;
}

/**
* The moment the account becomes usable again for a `family`/`model`
* request: the LATEST bound among the records that actually gate that
* request plus any active cooldown. Two deliberate differences from
* getRateLimitResetTimeForFamily, whose earliest-reset answer feeds wait
* displays: the account stays skipped while ANY gating record is active,
* so the earliest reset would send clients back into a 503 — and only the
* keys selection consults (`family`, plus `family:<model>` when a model is
* known; see isRateLimitedForFamily) may contribute, because another
* model's record does not block this request and would overstate its
* recovery. Null when nothing bounds recovery.
*/
export function getAccountRecoveryTimeForFamily(
account: {
rateLimitResetTimes?: Record<string, number | undefined>;
coolingDownUntil?: number;
},
now: number,
family: ModelFamily,
model?: string | null,
): number | null {
let latest: number | null = null;
const consider = (value: number | undefined): void => {
if (typeof value !== "number" || !Number.isFinite(value)) return;
if (value <= now) return;
if (latest === null || value > latest) latest = value;
};
const times = account.rateLimitResetTimes;
if (times) {
consider(times[getQuotaKey(family)]);
if (model) consider(times[getQuotaKey(family, model)]);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
consider(account.coolingDownUntil);
return latest;
}

export function formatRateLimitEntry(
account: { rateLimitResetTimes?: Record<string, number | undefined> },
now: number,
Expand Down
87 changes: 87 additions & 0 deletions test/account-status.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
formatRateLimitEntry,
getAccountRecoveryTimeForFamily,
getRateLimitResetTimeForFamily,
resolveActiveIndex,
} from "../lib/runtime/account-status.js";
Expand Down Expand Up @@ -101,3 +102,89 @@ describe("account status helpers", () => {
).toBe("resets in 4000ms");
});
});

describe("getAccountRecoveryTimeForFamily", () => {
it("returns the LATEST gating reset so a retry lands after real recovery", () => {
// Family-wide and requested-model records overlap: the account stays
// skipped until the later one expires, so the earliest reset would
// send a client straight back into a 503.
expect(
getAccountRecoveryTimeForFamily(
{
rateLimitResetTimes: {
codex: 3_000,
"codex:gpt-5-codex": 9_000,
},
},
1_000,
"codex",
"gpt-5-codex",
),
).toBe(9_000);
});

it("ignores records that do not gate the request", () => {
// Another model's record in the same family does not block this
// request (selection checks only the family key and the requested
// model's key), so it must not inflate the advertised recovery.
expect(
getAccountRecoveryTimeForFamily(
{
rateLimitResetTimes: {
codex: 3_000,
"codex:gpt-5.3-codex": 9_000,
},
},
1_000,
"codex",
"gpt-5-codex",
),
).toBe(3_000);
// Without a model only the family-wide key gates.
expect(
getAccountRecoveryTimeForFamily(
{ rateLimitResetTimes: { "codex:gpt-5-codex": 9_000 } },
1_000,
"codex",
),
).toBeNull();
});

it("ignores other families and expired records", () => {
expect(
getAccountRecoveryTimeForFamily(
{ rateLimitResetTimes: { "gpt-5.2": 9_000, codex: 500 } },
1_000,
"codex",
),
).toBeNull();
});

it("folds an active cooldown into the recovery moment", () => {
expect(
getAccountRecoveryTimeForFamily(
{ rateLimitResetTimes: { codex: 3_000 }, coolingDownUntil: 7_000 },
1_000,
"codex",
),
).toBe(7_000);
expect(
getAccountRecoveryTimeForFamily(
{ coolingDownUntil: 2_000 },
1_000,
"codex",
),
).toBe(2_000);
});

it("returns null when nothing bounds recovery", () => {
expect(getAccountRecoveryTimeForFamily({}, 1_000, "codex")).toBeNull();
expect(
getAccountRecoveryTimeForFamily(
{ coolingDownUntil: 900 },
1_000,
"codex",
),
).toBeNull();
});
});
Loading