Files
OmniRoute/open-sse/executors/opencodeRateLimited.ts
Dizzle 997cd4d509 fix(sse): stop retry wave on rate-limited 429 and drain 429 once (#13657)
The opencode executor classifies rate-limited 429 bodies (`classify429`, with real tests) and, when a whole account wave is exhausted, returns the last real upstream 429 — status, body, `Retry-After` and quota headers intact — so the provider error rules (monthly-quota cooldown) keep working.

Maintainer rework before merge (kept the idea, no default behavior change):
- The original stopped the cross-account wave at the first classified 429 and replaced the response with a synthetic one that dropped the body and headers; stopping early is now opt-in behind `OPENCODE_RATE_LIMITED_429_EARLY_STOP` (default off), the rate-limited account is still cooled down, the body is read as a bounded 8 KiB prefix from a clone and the original is never consumed, and the unused `status` input is gone.

Validated first on the combined board of all 38 PRs of this batch (10 merged as-is, 28 after the maintainer rework) on top of release/v3.8.51 c0f92ec: typecheck:core, check:open-sse-typecheck and check:dashboard-typecheck clean; ESLint clean on every changed file; file-size (rebaselined for the combined growth), complexity, cognitive-complexity, changelog-integrity, docs-counts, docs-sync, migration-numbering and i18n new-key gates green; 735 focused node:test cases with the only batch-caused failure (a flag-count assertion) fixed. Then re-validated alone on the fresh release tip right before this merge: ESLint on the changed files, typecheck:core, check:open-sse-typecheck, the file-size/complexity/changelog gates and this PR's own tests.

Thanks @maxmad64bis!
2026-09-15 22:01:56 -03:00

93 lines
3.3 KiB
TypeScript

/**
* opencodeRateLimited.ts — 429 classifier for the opencode executor loop (#13657).
*
* Leaf module: zero imports, no registry, no DB. Headers first: a parseable
* Retry-After alone marks a real rate limit; otherwise a bounded prefix of the
* body is matched against generic English rate-limit phrasings (derived from a
* captured 429 body). Anything else is a "burst" 429 that keeps the normal
* cross-account rotation. Only consulted when OPENCODE_RATE_LIMITED_429_EARLY_STOP
* is on; the classifier never rewrites the response it inspects.
*/
const RATE_LIMITED_SIGNALS: ReadonlyArray<RegExp> = [
/rate.?limited/i,
/usage.?limit/i,
/too many requests/i,
];
/** Bytes of a 429 body inspected by the classifier. */
export const RATE_LIMIT_BODY_SNIFF_BYTES = 8192;
/** Seconds until retry from a Retry-After value (delta-seconds or HTTP date), or null. */
export function parseRetryAfterSeconds(
retryAfter: string | number | null | undefined,
now = Date.now()
): number | null {
if (typeof retryAfter === "number") {
return Number.isFinite(retryAfter) && retryAfter > 0 ? Math.ceil(retryAfter) : null;
}
if (typeof retryAfter !== "string") return null;
const text = retryAfter.trim();
if (text === "") return null;
if (/^\d+$/.test(text)) return Math.max(Number(text), 1);
const ms = Date.parse(text);
if (Number.isFinite(ms)) return Math.max(Math.ceil((ms - now) / 1000), 1);
return null;
}
export type RateLimit429Verdict = "rate_limited" | "burst";
export function classify429(input: {
retryAfter?: string | number | null;
bodyText?: string | null;
}): RateLimit429Verdict {
if (parseRetryAfterSeconds(input.retryAfter) !== null) return "rate_limited";
const body = typeof input.bodyText === "string" ? input.bodyText : "";
if (body !== "" && RATE_LIMITED_SIGNALS.some((re) => re.test(body))) return "rate_limited";
return "burst";
}
/**
* Read at most `maxBytes` of a response body from a clone, then cancel the
* clone's reader. The original response keeps its full, unread body. Returns
* null when the body cannot be read.
*/
export async function readBodyPrefix(
response: Response,
maxBytes = RATE_LIMIT_BODY_SNIFF_BYTES
): Promise<string | null> {
if (!response.body) return "";
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
try {
reader = response.clone().body?.getReader();
} catch {
return null;
}
if (!reader) return "";
const decoder = new TextDecoder();
let text = "";
let bytes = 0;
try {
while (bytes < maxBytes) {
const { done, value } = await reader.read();
if (done || !value) break;
const chunk =
value.byteLength > maxBytes - bytes ? value.subarray(0, maxBytes - bytes) : value;
bytes += chunk.byteLength;
text += decoder.decode(chunk, { stream: true });
}
return text + decoder.decode();
} catch {
return null;
} finally {
void reader.cancel().catch(() => undefined);
}
}
/** Classify an upstream 429: Retry-After header first, bounded body prefix only if needed. */
export async function classifyUpstream429(response: Response): Promise<RateLimit429Verdict> {
const retryAfter = response.headers.get("retry-after");
if (parseRetryAfterSeconds(retryAfter) !== null) return "rate_limited";
return classify429({ retryAfter, bodyText: await readBodyPrefix(response) });
}