mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-17 20:32:25 +03:00
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!
93 lines
3.3 KiB
TypeScript
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) });
|
|
}
|