Files
OmniRoute/tests/unit/refresh-serializer-spacing.test.ts
diegosouzapw 6d2e695882 fix(sse): stop Codex multi-account family-revocation cascade on quota-sync
The Quota / Providers dashboard (POST /api/usage/provider-limits ->
syncAllProviderLimits, GET /api/usage/[connectionId]) calls
refreshAndUpdateCredentials() per connection, in concurrent chunks. For
rotating-refresh providers (Codex/OpenAI share one Auth0 client_id) the
single-use refresh_token is rotated on every refresh; refreshing siblings
concurrently makes Auth0 revoke the whole token family (openai/codex#9648),
killing every account but the last with [403] <!DOCTYPE html>. On the affected
VM expires_at was persisted as ~0 so needsRefresh() was effectively always
true -> every codex account refreshed on every page visit -> guaranteed cascade.

Fix #1: refreshAndUpdateCredentials skips proactive refresh for rotating
providers (rotationGroupFor) and reuses the current access_token for the quota
fetch; genuine expiry is handled by the reactive, serialized 401 path.

Fix #2 (defense in depth): serializeRefresh inserts a settle gap between two
QUEUED sibling refreshes (default 2000ms, CODEX_REFRESH_SPACING_MS, '0' to opt
out) but releases a lone refresh immediately, adding no latency to the reactive
request path.

Tests: codex-quota-sync-no-proactive-refresh (skip + non-rotating guard),
refresh-serializer-spacing (default/opt-out + lone-vs-queued behavior).
2026-05-31 19:13:41 -03:00

80 lines
3.0 KiB
TypeScript

/**
* Codex multi-account cascade — defense-in-depth refresh spacing (Fix #2).
*
* Serialization (concurrency=1 per Auth0 family) already stops OVERLAPPING
* refreshes. Spacing adds a settle gap BETWEEN two consecutive sibling refreshes
* so the second account never presents a refresh_token that Auth0 is still
* rotating for the family (the `refresh_token_reused` race, openai/codex#9648).
*
* Two invariants:
* - The gap is only paid when a sibling is already queued behind us. A lone
* refresh — the common reactive case during a real request — is released
* immediately, so we add ZERO latency to user traffic.
* - The gap has a protective non-zero default, is tunable via
* CODEX_REFRESH_SPACING_MS, and can be disabled with "0".
*/
import test from "node:test";
import assert from "node:assert/strict";
import {
serializeRefresh,
getRefreshSpacingMs,
__resetRefreshSerializerForTest,
} from "../../open-sse/services/refreshSerializer.ts";
const ENV = "CODEX_REFRESH_SPACING_MS";
function withEnv(value: string | undefined, fn: () => void | Promise<void>) {
const prev = process.env[ENV];
if (value === undefined) delete process.env[ENV];
else process.env[ENV] = value;
__resetRefreshSerializerForTest();
const restore = () => {
if (prev === undefined) delete process.env[ENV];
else process.env[ENV] = prev;
__resetRefreshSerializerForTest();
};
return Promise.resolve()
.then(fn)
.finally(restore);
}
test("getRefreshSpacingMs defaults to a protective non-zero gap, honors overrides and explicit opt-out", () => {
const prev = process.env[ENV];
try {
delete process.env[ENV];
assert.equal(getRefreshSpacingMs(), 2000, "unset -> protective default");
process.env[ENV] = "0";
assert.equal(getRefreshSpacingMs(), 0, "explicit 0 -> opt out");
process.env[ENV] = "750";
assert.equal(getRefreshSpacingMs(), 750, "explicit value honored");
process.env[ENV] = "not-a-number";
assert.equal(getRefreshSpacingMs(), 2000, "garbage -> protective default");
} finally {
if (prev === undefined) delete process.env[ENV];
else process.env[ENV] = prev;
}
});
test("a lone rotating refresh is released immediately (no added latency on the reactive request path)", async () => {
await withEnv("1000", async () => {
const start = Date.now();
await serializeRefresh("codex", async () => "ok");
const elapsed = Date.now() - start;
assert.ok(elapsed < 400, `a lone refresh must not pay the spacing gap; took ${elapsed}ms`);
});
});
test("queued sibling refreshes in the same group are spaced apart by the configured gap", async () => {
await withEnv("120", async () => {
const starts: number[] = [];
const run = () =>
serializeRefresh("codex", async () => {
starts.push(Date.now());
});
await Promise.all([run(), run()]);
assert.equal(starts.length, 2, "both refreshes ran");
const gap = starts[1] - starts[0];
assert.ok(gap >= 100, `the second sibling must start after the gap; gap was ${gap}ms`);
});
});