Files
openclaw/extensions/codex/src/app-server/rate-limit-cache.test.ts
Peter Steinberger 554d772c1a refactor(codex): keyed turn routing, client-scoped rate limits, and resume subscription safety (#101376)
* feat(codex): scope app-server rate limits to the physical client

Replace the process-global rate-limit cache with a WeakMap keyed by the
physical app-server client, tracking per-limitId revisions. Rolling
account/rateLimits/updated notifications merge sparsely per the protocol
contract (credits/individualLimit/planType survive nulls), and
usage-limit errors only trust a snapshot for auth-profile blocking when
the same client observed a primary update during the failing turn's
startup. Fixes cross-client rate-limit bleed in usage-limit error
messages. A new client-runtime module installs the
account/chatgptAuthTokens/refresh handler and the rate-limit observer
once per physical client, replacing per-start inline handlers in
shared-client, run-attempt, and side-question.

* refactor(codex): split thread/resume subscription safety into thread-resume

Move the thread/resume request out of thread-lifecycle into a dedicated
thread-resume module that retires the exact physical client when resume
acceptance is indeterminate: only a structured RPC rejection proves
Codex holds no subscription, so any other failure abandons the client
instead of returning it to the shared pool. Resume responses naming a
different thread now fail closed (assertCodexThreadResumeSubscription),
and the fresh-thread fallback requires a released subscription unless
the resume was a proven RPC rejection.

* refactor(codex): replace client-factory positional DI with shared-client factory

Delete the lazy positional-argument CodexAppServerClientFactory and use
the options-object factory type exported from shared-client. Callers in
run-attempt, compact, bounded-turn, provider-capabilities, and the
web-search provider now default to getLeasedSharedCodexAppServerClient
directly; the lazy indirection was ineffective because those modules
already import shared-client statically.

* feat(codex): route app-server turn traffic through a keyed turn router

Install one turn router per physical app-server client and replace the
broad per-attempt notification/request fanout with explicit per-thread
routes. Attempt startup reserves the thread route (before thread/resume
on the resume path, so early notifications buffer instead of racing),
run-attempt activates it with receive-time, queued, and request
handlers, arms the route before turn/start, binds the accepted turn id
to flush buffered traffic in wire order, and releases the route on
cleanup. Requests for a pending turn wait for binding instead of being
auto-declined, native turn completion waits use route state instead of
scanning buffered notifications, and correlation readers now match the
canonical v2 wire shapes only (top-level threadId, nested turn.id). The
unscoped response-delta lease-count attribution and its client API are
deleted along with the retired correlation predicates.

* test(codex): reset the shared binding store between thread-lifecycle tests

SQLite bindings are keyed by session identity rather than the per-test
temp dir, so earlier tests leaked resumable threads into fresh-start
expectations. The old silent resume-failure fallback masked the leak;
subscription safety surfaces it.

* test(codex): reset the binding store between delivery-hint iterations

The loop reuses one session identity across iterations, so the previous
iteration's thread would resume against a harness that cannot serve it.
2026-07-07 07:25:15 +01:00

125 lines
4.2 KiB
TypeScript

// Codex tests cover physical-client rate-limit snapshot ownership and rolling merges.
import { describe, expect, it } from "vitest";
import type { CodexAppServerClient } from "./client.js";
import {
mergeCodexRateLimitsUpdate,
readCodexRateLimitsRevision,
readRecentCodexRateLimits,
rememberCodexRateLimitsRead,
} from "./rate-limit-cache.js";
function clientIdentity(): CodexAppServerClient {
return {} as unknown as CodexAppServerClient;
}
describe("Codex rate-limit cache", () => {
it("isolates snapshots by physical client", () => {
const first = clientIdentity();
const second = clientIdentity();
expect(readCodexRateLimitsRevision(first)).toBe(0);
rememberCodexRateLimitsRead(first, { rateLimits: { limitId: "first" } }, 100);
rememberCodexRateLimitsRead(second, { rateLimits: { limitId: "second" } }, 200);
expect(readCodexRateLimitsRevision(first, "first")).toBe(1);
expect(readCodexRateLimitsRevision(second, "second")).toBe(1);
expect(readRecentCodexRateLimits(first, { nowMs: 250 })).toEqual({
rateLimits: { limitId: "first" },
});
expect(readRecentCodexRateLimits(second, { nowMs: 250 })).toEqual({
rateLimits: { limitId: "second" },
});
expect(readRecentCodexRateLimits(first, { nowMs: 301, maxAgeMs: 200 })).toBeUndefined();
expect(readRecentCodexRateLimits(second, { nowMs: 301, maxAgeMs: 200 })).toEqual({
rateLimits: { limitId: "second" },
});
});
it("merges sparse rolling updates without clearing account metadata", () => {
const client = clientIdentity();
const codexSnapshot = {
limitId: "codex",
limitName: "Codex",
primary: { usedPercent: 10, windowDurationMins: 300, resetsAt: 1000 },
secondary: { usedPercent: 20, windowDurationMins: 10_080, resetsAt: 2000 },
credits: { hasCredits: true, unlimited: false, balance: "5" },
individualLimit: {
limit: "25000",
used: "8000",
remainingPercent: 68,
resetsAt: 3000,
},
planType: "pro",
rateLimitReachedType: "rate_limit_reached",
};
const otherSnapshot = {
limitId: "codex_other",
limitName: "Other",
primary: { usedPercent: 30, windowDurationMins: 60, resetsAt: 4000 },
secondary: null,
credits: null,
individualLimit: null,
planType: "pro",
rateLimitReachedType: null,
};
rememberCodexRateLimitsRead(client, {
rateLimits: codexSnapshot,
rateLimitsByLimitId: { codex: codexSnapshot, codex_other: otherSnapshot },
});
mergeCodexRateLimitsUpdate(client, {
rateLimits: {
limitId: null,
limitName: null,
primary: { usedPercent: 90, windowDurationMins: 300, resetsAt: 5000 },
secondary: null,
credits: null,
individualLimit: null,
planType: null,
rateLimitReachedType: null,
},
});
mergeCodexRateLimitsUpdate(client, {
rateLimits: {
limitId: "codex_other",
limitName: null,
primary: { usedPercent: 75, windowDurationMins: 60, resetsAt: 6000 },
secondary: null,
credits: null,
individualLimit: null,
planType: null,
rateLimitReachedType: null,
},
});
expect(readCodexRateLimitsRevision(client)).toBe(2);
expect(readCodexRateLimitsRevision(client, "codex_other")).toBe(2);
const mergedCodexSnapshot = {
limitId: "codex",
limitName: null,
primary: { usedPercent: 90, windowDurationMins: 300, resetsAt: 5000 },
secondary: null,
credits: codexSnapshot.credits,
individualLimit: codexSnapshot.individualLimit,
planType: "pro",
rateLimitReachedType: null,
};
const mergedOtherSnapshot = {
limitId: "codex_other",
limitName: null,
primary: { usedPercent: 75, windowDurationMins: 60, resetsAt: 6000 },
secondary: null,
credits: codexSnapshot.credits,
individualLimit: codexSnapshot.individualLimit,
planType: "pro",
rateLimitReachedType: null,
};
expect(readRecentCodexRateLimits(client)).toEqual({
rateLimits: mergedCodexSnapshot,
rateLimitsByLimitId: {
codex: mergedCodexSnapshot,
codex_other: mergedOtherSnapshot,
},
});
});
});