feat(sse): per-egress pacing + fleet-wide backoff for opencode rotation, opt-in (#14290)

Per-egress pacing and fleet-wide suspect backoff for OpenCode free-tier rotation,
layered on #13924's park-and-replay: paces requests per egress identity, marks an
egress fleet-suspect after repeated transient 429s, and hands a suspect slot
budget over to the park/heartbeat path instead of surfacing the last 429.
Opt-in via OPENCODE_EGRESS_THROTTLE_ENABLED (default off) — with the flag off every
hook is a no-op and behavior is identical to the tip.

Stacked on #13924 (merged as 893fef9c); reconciled onto the merged form.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
Dizzle
2026-09-21 18:23:55 +02:00
committed by GitHub
parent 893fef9c67
commit 34113170f4
6 changed files with 1007 additions and 170 deletions

View File

@@ -0,0 +1 @@
- **feat(sse):** multi-account rotation now spreads sends per network egress and eases off fleet-wide when throttled, opt-in via `OPENCODE_EGRESS_THROTTLE_ENABLED=1` ([#14290](https://github.com/diegosouzapw/OmniRoute/pull/14290)) — thanks @maxmad64bis

View File

@@ -1,4 +1,5 @@
{
"_rebaseline_2026_09_21_14290_stack_handover_growth": "Stacked on #13924 (rewritten/squash-merged as 893fef9c on release/v3.8.51). PR #14290's own growth on top of that parent: open-sse/executors/opencode.ts 1233->1247 (+14 irreducible seam: settle429Arm park arm forcing burstStreak to threshold + handover comment; park/replay block owned by #13924, throttle leaf opencodeEgressThrottle.ts 543 lines under cap). Covered by tests/unit/opencode-429-park-resume.test.ts handover case (8/8) + tests/unit/opencode-egress-throttle.test.ts (22/22).",
"_rebaseline_2026_09_18_merge_train_8_frozen_growth": "Owner-approved train rebaseline (2026-09-18, /merge-prs; precedent _rebaseline_2026_07_23_v3849_merge_train_15). Own growth of 32 merge-ready contributor PRs that each add irreducible call-site/plumbing lines to an already-frozen file, measured on the combined merge-train tip 04cf8095 (release tip green before boarding). Per-file (old->new, contributing PRs): src/app/(dashboard)/dashboard/combos/page.tsx 5080->5091 (#13951); src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx 2491->2493 (#13533); src/app/api/v1/models/catalog.ts 2117->2127 (#13994); src/lib/db/apiKeys.ts 1659->1671 (#12952, #13861); src/lib/tokenHealthCheck.ts 1221->1254 (#13444, #13874); src/shared/components/RequestLoggerDetail.tsx 1200->1210 (#13373); src/shared/components/RequestLoggerV2.tsx 1718->1748 (#13373); src/shared/middleware/chatBodyAdmission.ts 1200->1206 (#13823); src/sse/handlers/chat.ts 2541->2547 (combined growth); src/sse/services/auth.ts 3582->3592 (combined growth); open-sse/executors/antigravity.ts 1665->1717 (#13125, #13318, #13659); open-sse/executors/codex.ts 1553->1570 (#13708); open-sse/executors/cursor.ts 1847->1868 (#13125); open-sse/executors/deepseek-web.ts 1200->1224 (#13226); open-sse/executors/default.ts 1200->1205 (#11828); open-sse/handlers/imageGeneration.ts 3304->3334 (#12982); open-sse/services/accountFallback.ts 2507->2515 (#13008); open-sse/services/combo/executeTargetAttempt.ts 1228->1258 (#12235); open-sse/services/combo/roundRobinCombo.ts 1221->1261 (#12235); open-sse/services/rateLimitManager.ts 1200->1329 (#13895); open-sse/translator/response/openai-responses.ts 1466->1518 (#12841, #13956); open-sse/utils/cursorAgentProtobuf.ts 1547->1588 (#13125); open-sse/utils/stream.ts 3140->3239 (#12688, #12855); tests/integration/chat-pipeline.test.ts 1740->1756 (#12966); tests/unit/account-fallback-service.test.ts 2056->2072 (#13040); tests/unit/chatcore-translation-paths.test.ts 3449->3546 (#13856, #13972); tests/unit/token-refresh-service.test.ts 1407->1408 (combined growth); tests/unit/translator-openai-to-gemini.test.ts 1625->1809 (#13318, #13848). Files previously under the 1200 cap that crossed it are frozen at the measured size. Structural shrink of these god-files stays tracked in #3501; the ceilings never move up again outside a documented entry. ADJUST (train 8d re-measure after #13548 ejection and #14101 landing): open-sse/services/accountFallback.ts 2515->2517 (#13008 +22, #13350 +7, #13984 +2, #13040 +2 on a tip at 2499).",
"_rebaseline_2026_09_18_14065_codex_reasoning_whitelist": "Release-tip drift: open-sse/executors/codex.ts 1552->1553 (+1) from #14065 (fix(codex): whitelist reasoning object keys before the wire, #13643), merged 2026-09-18 without its own rebaseline — the PR->release fast-gates do not run check:file-size, so the tip went red for every train boarding afterwards. Absorbed at the release tip by the /merge-prs captain session (owner-approved train-rebaseline policy, 2026-09-18). Structural shrink tracked in #3501.",
"_rebaseline_2026_09_18_restore_13643_after_clobber": "Restore of #13643 after e7999c477b clobbered the whitelist at the release tip (see #14062). open-sse/executors/codex.ts 1530->1552 (+22): keep the tip force-rule precedence and restore OpenRouter-style enabled:false plus the reasoning-object key whitelist before the wire. Covered by tests/unit/codex-reasoning-wire-whitelist.test.ts.",
@@ -525,7 +526,7 @@
"open-sse/executors/deepseek-web.ts": 1224,
"open-sse/executors/default.ts": 1205,
"open-sse/services/rateLimitManager.ts": 1329,
"open-sse/executors/opencode.ts": 1233
"open-sse/executors/opencode.ts": 1247
},
"_rebaseline_2026_09_15_roundrobin_dashboard_events": "Fix #13089 (Combo Studio Live dashboard shows an empty backlog for round-robin combos): open-sse/services/combo/roundRobinCombo.ts 1205->1213. Round-robin is the only combo strategy that bypasses handleComboChat/executeTargetAttempt.ts, the path that publishes the combo.target.attempt/succeeded/failed EventBus events the Live dashboard listens for — so round-robin completions never showed up. The new call-site wiring (createRRDashboardEvents(...) instantiated once per target, one-line .attempt()/.succeeded()/.failed() calls at the 6 existing dispatch/outcome points) is the emitter logic actually extracted into a new module, open-sse/services/combo/rrDashboardEvents.ts — this is the minimum irreducible footprint for wiring 6 required call sites into 6 fixed control-flow points of the frozen file. Covered by tests/unit/issue-13089-roundrobin-live-ws-events.test.ts (2 tests: success + failure paths).",
"_rebaseline_base_2026_08_10_proxyfetch": "Base-red fix (green-prs sweep, issue #9985): open-sse/utils/proxyFetch.ts 1207 > cap 1000 — new proxied-TLS fetch helper introduced by the Fal reference-image work. Owner-authorized quick rebaseline to green; structural slim tracked for v3.9.0.",

View File

@@ -61,7 +61,8 @@ import {
sleepAbortable,
transientRetryDelayMs,
} from "./opencodeTransientFailure.ts";
import { isProxyAvoided, noteProxyRefusal, proxyEgressKey } from "../utils/proxyRefusalMemory.ts";
import { isProxyAvoided, proxyEgressKey } from "../utils/proxyRefusalMemory.ts";
import * as egressPacing from "./opencodeEgressThrottle.ts";
import {
isNetworkRotationSharedEgressGuardEnabled,
isProxySkipRecentlyFailedEnabled,
@@ -70,7 +71,6 @@ import {
isOpencodeRateLimited429EarlyStopEnabled,
isOpencodeParkAndResumeEnabled,
} from "@/shared/utils/featureFlags";
import { classifyUpstream429 } from "./opencodeRateLimited.ts";
import {
BURST_PARK_THRESHOLD,
parkWaitMs,
@@ -615,7 +615,7 @@ export class OpencodeExecutor extends BaseExecutor {
const skipRecentlyFailed = isProxySkipRecentlyFailedEnabled();
let directTried = false;
// Stalls before the first Responses byte: one rotation, then fail fast.
let stalledAttempts = 0;
const stallCounter = { attempts: 0 };
// A response an opt-in branch rotated away from. It stays lastResult (and
// intact) until a newer attempt replaces it, then its body is cancelled.
let abandonedResponse: Response | null = null;
@@ -627,6 +627,7 @@ export class OpencodeExecutor extends BaseExecutor {
let transientPausedMs = 0;
let burstStreak = 0,
parked = false;
const requestPacing = egressPacing.initEgressPacingForRequest(); // Off by default.
for (let attempt = 0; attempt < this.accounts.length + emptyRejectionBudget; attempt++) {
const isProxiedCandidate = (a: OpencodeAccountState): boolean => {
@@ -708,11 +709,18 @@ export class OpencodeExecutor extends BaseExecutor {
// Pin egress to this account's proxy for the whole BaseExecutor dispatch
// (incl. its intra-URL 429 retries). skipUpstreamRetry lets THIS loop own
// the cross-account 429 fallback instead of BaseExecutor's same-key retry.
const paced = await egressPacing.startPacedDispatch(
requestPacing,
account,
isProxiedCandidate,
() => this.pickAccountWith(isProxiedCandidate),
input.signal
);
const egressRelease = paced.release;
account = paced.account;
let result: HttpExecuteResult;
try {
// super.execute() here always dispatches the HTTP path (opencode is an
// OpenAI-compatible API, never the web/scraping bare-Response arm) —
// see base.ts:290-294.
// super.execute() dispatches the HTTP path (never the web/scraping arm).
result = (await guardStall(
await runWithProxyContext(account.proxy, () =>
super.execute({ ...input, skipUpstreamRetry: true })
@@ -723,17 +731,18 @@ export class OpencodeExecutor extends BaseExecutor {
// Stall guard: headers arrived, so the egress works — never a shared-egress
// outage; proxied and proxy-less accounts rotate alike. A client abort never rotates.
if (stallWindowMs > 0 && (isResponsesFirstByteTimeout(err) || input.signal?.aborted)) {
if (input.signal?.aborted) throw err;
markCooldown(account);
const stallKey = proxyKeyOf(account.proxy);
if (stallKey !== null) geoTriedProxyKeys.add(stallKey);
else directTried = true;
const rotate = ++stalledAttempts === 1;
if (input.signal?.aborted) egressPacing.throwPacedError(egressRelease, err);
const rotate = egressPacing.settleStalledDispatch(egressRelease, account, {
tried: geoTriedProxyKeys,
stalled: stallCounter,
cooldown: markCooldown,
markDirect: () => (directTried = true),
});
log?.warn?.(
"OPENCODE",
`${cid}Responses stream stalled on account ${masked}, ${rotate ? "rotating to next…" : "not rotating again"} (${reason})`
`${cid}stream stalled on account ${masked}, ${rotate ? "rotating…" : "not rotating again"} (${reason})`
);
if (!rotate) throw err;
if (!rotate) egressPacing.throwPacedError(egressRelease, err);
continue;
}
transientStreak = 0;
@@ -750,21 +759,23 @@ export class OpencodeExecutor extends BaseExecutor {
lastSharedEgressError = err;
log?.warn?.(
"OPENCODE",
`${cid}network error on account ${masked} (no dedicated proxy, shared egress), cooldown applied — trying next available account… (${reason})`
`${cid}network error on account ${masked} (no dedicated proxy, shared egress), cooldown — trying next… (${reason})`
);
egressPacing.releasePacingSlot(egressRelease);
continue;
}
log?.warn?.(
"OPENCODE",
`${cid}network error on account ${masked} (no dedicated proxy, shared egress) — not rotating (${reason})`
);
throw err;
egressPacing.throwPacedError(egressRelease, err);
}
markCooldown(account);
log?.warn?.(
"OPENCODE",
`${cid}network error on account ${masked}, rotating to next… (${reason})`
);
egressPacing.releasePacingSlot(egressRelease);
continue;
}
discardResponseBody(abandonedResponse);
@@ -774,174 +785,177 @@ export class OpencodeExecutor extends BaseExecutor {
transientStreak = 0;
if (result.response.status !== 429) burstStreak = 0;
const status = result.response.status;
if (status === 429) {
markCooldown(account);
// The provider refused through this member: set it aside beyond the account
// cooldown. A direct account has a null key and is never set aside.
const setAsideMs = skipRecentlyFailed
? noteProxyRefusal(proxyEgressKey(account.proxy), "ip_quota_429")
: null;
// Opt-in (#13657): a 429 that names a real rate limit stops the wave and
// the real upstream 429 is returned untouched (body, Retry-After, quota
// headers), so provider error rules still apply. Flag off → rotate.
if (
isOpencodeRateLimited429EarlyStopEnabled() &&
(await classifyUpstream429(result.response)) === "rate_limited"
) {
log?.warn?.(
"OPENCODE",
`${cid}rate-limited 429 on account ${masked}, stopping the account wave`
try {
const status = result.response.status;
if (status === 429) {
markCooldown(account);
const setAsideMs = egressPacing.noteRefusedMember(account.proxy, skipRecentlyFailed);
// Opt-in (#13657): a 429 that names a real rate limit stops the wave and
// the real upstream 429 is returned untouched (body, Retry-After, quota
// headers), so provider error rules still apply. Flag off → rotate.
// The settle releases the slot exactly once; a burst parks the
// request once its slot budget is spent.
const arm = await egressPacing.settle429Arm(
egressRelease,
requestPacing,
result.response,
isOpencodeRateLimited429EarlyStopEnabled
);
return result;
}
log?.warn?.(
"OPENCODE",
`${cid}Rate limited (429) on account ${masked}` +
(setAsideMs ? `, member set aside for ${Math.round(setAsideMs / 1000)}s` : "") +
", rotating to next…"
);
burstStreak += 1;
if (!parked && isOpencodeParkAndResumeEnabled()) {
const marker = await readPoolStrainMarker();
if (burstStreak >= BURST_PARK_THRESHOLD || marker.fresh) {
parked = true;
log?.warn?.(
"OPENCODE",
`${cid}burstStreak=${burstStreak} freshD2=${marker.fresh} park`
);
const p = await runParkAndReplay(
{
execute: (i: ExecuteInput) =>
super.execute(i) as Promise<ExecutorExecuteResult & { response: Response }>,
markSuccess: (a: OpencodeAccountState) => markSuccess(a),
sleep: this.parkSleep,
accounts: this.accounts,
},
input,
parkWaitMs(marker.fresh ? marker.ttlLeftMs : null),
result,
log,
cid
);
if (p && p !== result) return this.normalizeMuseSparkResponse(input, p);
if (p) {
discardResponseBody(abandonedResponse);
return this.normalizeMuseSparkResponse(input, result);
egressPacing.log429Outcome(log, cid, arm, masked, setAsideMs);
if (arm === "stop") return result;
if (arm === "park") {
// Slot budget spent: join the park-and-replay path below
// instead of surfacing the last 429. The park flag can still
// veto (fail-closed: plain break).
if (!isOpencodeParkAndResumeEnabled()) break;
burstStreak = Math.max(burstStreak + 1, BURST_PARK_THRESHOLD);
} else {
burstStreak += 1;
}
if (!parked && isOpencodeParkAndResumeEnabled()) {
const marker = await readPoolStrainMarker();
if (burstStreak >= BURST_PARK_THRESHOLD || marker.fresh) {
parked = true;
log?.warn?.(
"OPENCODE",
`${cid}burstStreak=${burstStreak} freshD2=${marker.fresh} park`
);
const p = await runParkAndReplay(
{
execute: (i: ExecuteInput) =>
super.execute(i) as Promise<ExecutorExecuteResult & { response: Response }>,
markSuccess: (a: OpencodeAccountState) => markSuccess(a),
sleep: this.parkSleep,
accounts: this.accounts,
},
input,
parkWaitMs(marker.fresh ? marker.ttlLeftMs : null),
result,
log,
cid
);
if (p && p !== result) return this.normalizeMuseSparkResponse(input, p);
if (p) {
discardResponseBody(abandonedResponse);
return this.normalizeMuseSparkResponse(input, result);
}
}
}
continue;
}
continue;
}
if (isRetriableUpstreamFailure(status)) {
const key = proxyKeyOf(account.proxy);
if (key !== null) geoTriedProxyKeys.add(key);
else directTried = true;
transientStreak = priorTransientStreak + 1;
log?.warn?.(
"OPENCODE",
`${cid}transient upstream ${status} on account ${masked} (proxy ${key ?? "direct"}), rotating to next…`
);
// Deliberately a separate branch from the 400-empty arm below,
// not one merged `if`: this arm never touches the body, the 400
// arm must clone-read it. Both share the predicate + tried-set.
// Single proxied account: one retry via the existing budget (a
// proxy-less single account takes the fast path, never the loop).
// Transient is not deterministic like geo: upstream may recover.
// No 0-retry guard here (it stays geo-only).
continue;
}
if (status === 403 || status === 451) {
let bodyText: string | null = null;
try {
bodyText = await result.response.clone().text();
} catch {
log?.debug?.("OPENCODE", "body read failed on geo-block check");
}
if (bodyText !== null && isOpencodeGeoBlocked(status, bodyText)) {
if (isRetriableUpstreamFailure(status)) {
const key = proxyKeyOf(account.proxy);
if (key !== null) geoTriedProxyKeys.add(key);
else directTried = true;
log?.warn?.(
"OPENCODE",
`${cid}geo-blocked on account ${masked} (proxy ${key ?? "direct"}), rotating to next…`
);
// Single account with a proxy: 0 retries (same egress = dead latency).
// (The fast path above already covers single-without-proxy; here length===1 WITH proxy.)
if (this.accounts.length === 1) return result;
continue;
}
// Opt-in (#13498): an upstream user_blocked refusal (403 or 451, same
// predicate) cools the refused account down, joins the tried-set and
// rotates at most once per request. Never a success mark. Flag off →
// falls through to the unchanged path below.
if (
bodyText !== null &&
isOpencodeUserBlocked(status, bodyText) &&
isOpencodeUserBlockedRotationEnabled()
) {
const key = proxyKeyOf(account.proxy);
if (key !== null) geoTriedProxyKeys.add(key);
else directTried = true;
markCooldown(account);
const rotate = userBlockedRotations === 0 && this.accounts.length > 1;
log?.warn?.(
"OPENCODE",
`${cid}user_blocked ${status} on account ${masked} (proxy ${key ?? "direct"}), ${rotate ? "rotating to next account once…" : "returning the refusal"}`
);
if (!rotate) return result;
userBlockedRotations++;
abandonedResponse = result.response;
continue;
}
// Free-tier refusal: upstream rejected the REQUEST (client identity or
// request shape), not this account. Every sibling account gets the same
// verdict from the same request, so rotating only adds latency; and the
// refusal must not touch account health — markSuccess would revive an
// evicted account. Return it untouched, health and cooldown unchanged.
if (bodyText !== null && isOpencodeFreeTierRefusal(status, bodyText)) {
log?.warn?.(
"OPENCODE",
`${cid}free-tier refusal ${status} on account ${masked} (proxy ${proxyKeyOf(account.proxy) ?? "direct"}), returning it unchanged (request-scoped, no rotation)`
);
noteResponseServed(account);
return result;
}
}
// Empty upstream rejection (malformed 400: no error field, no real
// content, finish_reason null — see isEmptyUpstreamRejection). Rotate/
// retry instead of propagating it as a fatal success: the observed
// envelope was marking subagent sessions as failed. Read the body ONLY
// for a 400 (never a 200/streaming — that would buffer the good path);
// classify, log, and continue. Neitheries markCooldown nor markSuccess:
// the failure is upstream's, not this account's.
if (status === 400) {
let bodyText: string | null = null;
try {
bodyText = await result.response.clone().text();
} catch {
log?.debug?.("OPENCODE", "body read failed on empty rejection check");
}
if (bodyText !== null && isRetriableUpstreamFailure(400, bodyText)) {
const chatcmplId = extractChatcmplId(bodyText);
transientStreak = priorTransientStreak + 1;
log?.warn?.(
"OPENCODE",
`${cid}upstream empty rejection on account ${masked} (${chatcmplId}), rotating to next…`
`${cid}transient upstream ${status} on account ${masked} (proxy ${key ?? "direct"}), rotating to next…`
);
// Deliberately a separate branch from the 400-empty arm below,
// not one merged `if`: this arm never touches the body, the 400
// arm must clone-read it. Both share the predicate + tried-set.
// Single proxied account: one retry via the existing budget (a
// proxy-less single account takes the fast path, never the loop).
// Transient is not deterministic like geo: upstream may recover.
// No 0-retry guard here (it stays geo-only).
continue;
}
// A 400 carrying a real error (or non-empty content): propagate
// immediately, untouched — same as before this change.
markOutcome(account, result.response);
return result;
}
markOutcome(account, result.response);
return this.finalizeForcedStream(input, this.normalizeMuseSparkResponse(input, result));
if (status === 403 || status === 451) {
let bodyText: string | null = null;
try {
bodyText = await result.response.clone().text();
} catch {
log?.debug?.("OPENCODE", "body read failed on geo-block check");
}
if (bodyText !== null && isOpencodeGeoBlocked(status, bodyText)) {
const key = proxyKeyOf(account.proxy);
if (key !== null) geoTriedProxyKeys.add(key);
else directTried = true;
log?.warn?.("OPENCODE", `${cid}geo-blocked on account ${masked}, rotating…`);
// Single account with a proxy: 0 retries (same egress = dead latency).
// (The fast path above already covers single-without-proxy; here length===1 WITH proxy.)
if (this.accounts.length === 1) return result;
continue;
}
// Opt-in (#13498): an upstream user_blocked refusal (403 or 451, same
// predicate) cools the refused account down, joins the tried-set and
// rotates at most once per request. Never a success mark. Flag off →
// falls through to the unchanged path below.
if (
bodyText !== null &&
isOpencodeUserBlocked(status, bodyText) &&
isOpencodeUserBlockedRotationEnabled()
) {
const key = proxyKeyOf(account.proxy);
if (key !== null) geoTriedProxyKeys.add(key);
else directTried = true;
markCooldown(account);
const rotate = userBlockedRotations === 0 && this.accounts.length > 1;
log?.warn?.(
"OPENCODE",
`${cid}user_blocked ${status} on account ${masked} (proxy ${key ?? "direct"}), ${rotate ? "rotating to next account once…" : "returning the refusal"}`
);
if (!rotate) return result;
userBlockedRotations++;
abandonedResponse = result.response;
continue;
}
// Free-tier refusal: upstream rejected the REQUEST (client identity or
// request shape), not this account. Every sibling account gets the same
// verdict from the same request, so rotating only adds latency; and the
// refusal must not touch account health — markSuccess would revive an
// evicted account. Return it untouched, health and cooldown unchanged.
if (bodyText !== null && isOpencodeFreeTierRefusal(status, bodyText)) {
log?.warn?.(
"OPENCODE",
`${cid}free-tier refusal ${status} on account ${masked} (proxy ${proxyKeyOf(account.proxy) ?? "direct"}), returning it unchanged (request-scoped, no rotation)`
);
noteResponseServed(account);
return result;
}
}
// Empty upstream rejection (malformed 400: no error field, no real
// content, finish_reason null — see isEmptyUpstreamRejection). Rotate/
// retry instead of propagating it as a fatal success: the observed
// envelope was marking subagent sessions as failed. Read the body ONLY
// for a 400 (never a 200/streaming — that would buffer the good path);
// classify, log, and continue. Neitheries markCooldown nor markSuccess:
// the failure is upstream's, not this account's.
if (status === 400) {
let bodyText: string | null = null;
try {
bodyText = await result.response.clone().text();
} catch {
log?.debug?.("OPENCODE", "body read failed on empty rejection check");
}
if (bodyText !== null && isRetriableUpstreamFailure(400, bodyText)) {
const chatcmplId = extractChatcmplId(bodyText);
transientStreak = priorTransientStreak + 1;
log?.warn?.(
"OPENCODE",
`${cid}upstream empty rejection on account ${masked} (${chatcmplId}), rotating to next…`
);
continue;
}
// A 400 carrying a real error (or non-empty content): propagate
// immediately, untouched — same as before this change.
markOutcome(account, result.response);
return result;
}
egressPacing.observePacingSuccess(requestPacing, result.response.ok);
markOutcome(account, result.response);
return this.finalizeForcedStream(input, this.normalizeMuseSparkResponse(input, result));
} finally {
// Single release point for every post-dispatch arm (5xx, 403/451,
// free-tier, 429, 400, success): the released-guard makes the 429
// internal release a harmless no-op.
egressPacing.releasePacingSlot(egressRelease);
}
}
// The loop exhausted without a result. If it's because every remaining

View File

@@ -0,0 +1,543 @@
/**
* opencodeEgressThrottle.ts — per-egress pacing plus fleet-wide backoff for the
* opencode multi-account rotation.
*
* Pure store, same pattern as proxyRefusalMemory: no flag reads at the hot path
* (the caller resolves config once and injects it), injectable clock (`nowMs`)
* and RNG (`rand`) so unit tests drive time deterministically, test-only
* reset/size helpers. Zero business imports — no breaker, no cooldown, no
* lockout, no featureFlags (activation is an env var local to this module,
* default off, same truthy motif as the opencodeHeaders falsy guard
* (opencodeHeaders.ts:61, negative form `/^(0|false|no|off)$/`).
*
* Fail-open everywhere: a saturated queue, an elapsed wait budget, a disabled
* module or an aborted wait resolves `null` — the caller proceeds WITHOUT a
* slot rather than ever rejecting a dispatchable request.
*/
import { sleepAbortable } from "./opencodeTransientFailure.ts";
import { classifyUpstream429, type RateLimit429Verdict } from "./opencodeRateLimited.ts";
import { proxyKeyOf } from "./opencodeGeoBlock.ts";
import { noteProxyRefusal, proxyEgressKey } from "../utils/proxyRefusalMemory.ts";
export const DIRECT_EGRESS_SENTINEL = "direct";
/** Upper bound on tracked egress keys (active egress keys stay far below refused-pair counts). */
export const MAX_EGRESS_KEYS = 512;
export interface EgressThrottleConfig {
enabled: boolean;
cap: number;
waitMinMs: number;
waitMaxMs: number;
waitBudgetMs: number;
fleetWindowMs: number;
fleetThreshold: number;
suspectMinMs: number;
suspectMaxMs: number;
suspectSlots: number;
}
export const EGRESS_THROTTLE_DEFAULTS: EgressThrottleConfig = {
enabled: false,
cap: 2,
waitMinMs: 5000,
waitMaxMs: 15000,
waitBudgetMs: 30000,
fleetWindowMs: 60000,
fleetThreshold: 10,
suspectMinMs: 60000,
suspectMaxMs: 120000,
suspectSlots: 2,
};
function envFlagEnabled(raw: string | undefined): boolean {
return raw != null && /^(1|true|yes|on)$/i.test(raw.trim());
}
function envNum(raw: string | undefined, fallback: number, min: number, max: number): number {
if (raw == null || raw.trim() === "") return fallback;
const n = Number(raw);
if (!Number.isFinite(n) || n < min || n > max) return fallback;
return n;
}
/**
* Resolve config from the environment (never throws: unreadable values fall
* back to defaults, off). Pass an explicit `env` in tests; defaults to
* `process.env`.
*/
export function resolveEgressThrottleConfig(
env: NodeJS.ProcessEnv = process.env
): EgressThrottleConfig {
const d = EGRESS_THROTTLE_DEFAULTS;
const waitMinMs = envNum(env.OPENCODE_EGRESS_THROTTLE_WAIT_MIN_MS, d.waitMinMs, 0, 120_000);
return {
enabled: envFlagEnabled(env.OPENCODE_EGRESS_THROTTLE_ENABLED),
cap: Math.floor(envNum(env.OPENCODE_EGRESS_THROTTLE_CAP, d.cap, 1, 64)),
waitMinMs,
waitMaxMs: Math.max(
waitMinMs,
envNum(env.OPENCODE_EGRESS_THROTTLE_WAIT_MAX_MS, d.waitMaxMs, 0, 120_000)
),
waitBudgetMs: envNum(env.OPENCODE_EGRESS_THROTTLE_WAIT_BUDGET_MS, d.waitBudgetMs, 0, 300_000),
fleetWindowMs: envNum(
env.OPENCODE_EGRESS_THROTTLE_FLEET_WINDOW_MS,
d.fleetWindowMs,
1_000,
600_000
),
fleetThreshold: Math.floor(
envNum(env.OPENCODE_EGRESS_THROTTLE_FLEET_THRESHOLD, d.fleetThreshold, 1, 10_000)
),
suspectMinMs: envNum(env.OPENCODE_EGRESS_THROTTLE_SUSPECT_MIN_MS, d.suspectMinMs, 0, 600_000),
suspectMaxMs: Math.max(
envNum(env.OPENCODE_EGRESS_THROTTLE_SUSPECT_MIN_MS, d.suspectMinMs, 0, 600_000),
envNum(env.OPENCODE_EGRESS_THROTTLE_SUSPECT_MAX_MS, d.suspectMaxMs, 0, 600_000)
),
suspectSlots: Math.floor(
envNum(env.OPENCODE_EGRESS_THROTTLE_SUSPECT_SLOTS, d.suspectSlots, 1, 64)
),
};
}
/** Egress key for an account proxy; `direct` accounts share one sentinel (shared egress). */
export function egressKeyOf(proxy: { host: string; port: number } | null): string {
return proxyEgressKey(proxy) ?? DIRECT_EGRESS_SENTINEL;
}
// ---------------------------------------------------------------------------
// Per-egress semaphore
// ---------------------------------------------------------------------------
interface Waiter {
startWait: (slot: () => void) => void;
abort: () => void;
enqueuedAt: number;
}
interface EgressGate {
running: number;
queue: Waiter[];
lastUsed: number;
}
const gates = new Map<string, EgressGate>();
function getGate(key: string, nowMs: number): EgressGate {
let gate = gates.get(key);
if (!gate) {
gate = { running: 0, queue: [], lastUsed: nowMs };
gates.set(key, gate);
if (gates.size > MAX_EGRESS_KEYS) {
// Evict the stalest idle gate (never one with running slots or waiters).
let oldestKey: string | undefined;
let oldestUsed = Infinity;
for (const [k, g] of gates) {
if (k !== key && g.running === 0 && g.queue.length === 0 && g.lastUsed < oldestUsed) {
oldestUsed = g.lastUsed;
oldestKey = k;
}
}
if (oldestKey !== undefined) gates.delete(oldestKey);
else gates.delete(gates.keys().next().value as string);
}
}
gate.lastUsed = nowMs;
return gate;
}
function createReleaseFn(key: string): () => void {
let released = false;
return () => {
if (released) return;
released = true;
const gate = gates.get(key);
if (!gate || gate.running <= 0) return;
gate.running--;
gate.lastUsed = Date.now();
// Drain FIFO while slots are free.
while (gate.queue.length > 0 && gate.running < currentCap(key)) {
const next = gate.queue.shift();
if (!next) break;
gate.running++;
next.startWait(createReleaseFn(key));
}
if (gate.running === 0 && gate.queue.length === 0) {
gates.delete(key);
caps.delete(key); // m-d: caps follows gates so idle keys leave no residue
}
};
}
// Cap recorded per key at acquire time (config is per-process; last writer wins,
// same lazy pattern as rateLimitSemaphore.getGate).
const caps = new Map<string, number>();
function currentCap(key: string): number {
return caps.get(key) ?? EGRESS_THROTTLE_DEFAULTS.cap;
}
export interface AcquireEgressSlotOptions {
signal?: AbortSignal | null;
rand?: () => number;
nowMs?: number;
}
/**
* Acquire one of `cfg.cap` concurrent slots for `key`. Resolves a release
* callback (call exactly once, in `finally`), or `null` when the caller must
* proceed WITHOUT a slot: module disabled, empty key, queue wait elapsed
* (5-15 s jittered, bounded by the per-call wait budget), or client abort.
*/
export async function acquireEgressSlot(
key: string,
cfg: EgressThrottleConfig,
opts: AcquireEgressSlotOptions = {}
): Promise<(() => void) | null> {
const nowMs = opts.nowMs ?? Date.now();
if (!cfg.enabled || !key) return null;
caps.set(key, cfg.cap);
const gate = getGate(key, nowMs);
if (gate.running < cfg.cap) {
gate.running++;
return createReleaseFn(key);
}
const rand = opts.rand ?? Math.random;
const waitMs = Math.min(
cfg.waitMinMs + rand() * Math.max(0, cfg.waitMaxMs - cfg.waitMinMs),
Math.max(0, cfg.waitBudgetMs)
);
if (waitMs <= 0) return null;
return new Promise<(() => void) | null>((resolve) => {
let settled = false;
const waiter: Waiter = {
enqueuedAt: nowMs,
startWait: (slot) => {
if (settled) return slot();
settled = true;
resolve(slot);
},
abort: () => {
if (settled) return;
settled = true;
const idx = gate.queue.indexOf(waiter);
if (idx !== -1) gate.queue.splice(idx, 1);
resolve(null);
},
};
gate.queue.push(waiter);
void sleepAbortable(waitMs, opts.signal ?? null).then((elapsed) => {
if (!elapsed) waiter.abort();
else {
// Wait elapsed without a slot: fail open, leave the queue.
const idx = gate.queue.indexOf(waiter);
if (idx !== -1) gate.queue.splice(idx, 1);
if (!settled) {
settled = true;
resolve(null);
}
}
});
opts.signal?.addEventListener("abort", waiter.abort, { once: true });
});
}
// Fleet-wide backoff state (burst 429s only; rate-limit verdicts stay on the
// early-stop path and are never recorded here)
// ---------------------------------------------------------------------------
let burst429At: number[] = [];
let suspectUntil = 0;
let suspectSeedRand: (() => number) | null = null;
function pruneBursts(nowMs: number, windowMs: number): void {
const cutoff = nowMs - windowMs;
burst429At = burst429At.filter((t) => t > cutoff);
}
/**
* Record one burst (non-`rate_limited`) 429. Callers only invoke this once the
* early-stop path has ruled out a rate-limit verdict, so rate-limit verdicts
* stay on the early-stop path and never reach this counter. Time is
* injectable; the window is read lazily from the last resolved fleet window.
*/
export function noteEgress429(nowMs: number = Date.now()): void {
if (!lastEnabled) return;
burst429At.push(nowMs);
pruneBursts(nowMs, lastFleetWindowMs);
if (burst429At.length >= lastFleetThreshold && nowMs >= suspectUntil) {
const rand = suspectSeedRand ?? Math.random;
suspectUntil =
nowMs + lastSuspectMinMs + rand() * Math.max(0, lastSuspectMaxMs - lastSuspectMinMs);
}
}
// Last resolved fleet params (set by configureFleetForTest / seam wiring;
// defaults otherwise). Kept module-local so noteEgress429 stays sync and cheap.
let lastFleetWindowMs = EGRESS_THROTTLE_DEFAULTS.fleetWindowMs;
let lastFleetThreshold = EGRESS_THROTTLE_DEFAULTS.fleetThreshold;
let lastSuspectMinMs = EGRESS_THROTTLE_DEFAULTS.suspectMinMs;
let lastSuspectMaxMs = EGRESS_THROTTLE_DEFAULTS.suspectMaxMs;
let lastEnabled = false;
/** Wire the fleet params from resolved config (called once per request path setup). */
export function configureFleetFromConfig(cfg: EgressThrottleConfig): void {
lastEnabled = cfg.enabled;
lastFleetWindowMs = cfg.fleetWindowMs;
lastFleetThreshold = cfg.fleetThreshold;
lastSuspectMinMs = cfg.suspectMinMs;
lastSuspectMaxMs = cfg.suspectMaxMs;
}
/** The first success clears everything: burst history + suspect. */
export function noteEgressSuccess(): void {
burst429At = [];
suspectUntil = 0;
}
/** Lazy read: an expired suspect is invisible without any timer (breaker/cooldown motif). */
export function isFleetSuspect(nowMs: number = Date.now()): boolean {
return nowMs < suspectUntil;
}
// ---------------------------------------------------------------------------
// Request wiring: keeps the executor seam small (init + one call per arm)
// ---------------------------------------------------------------------------
/** Per-request pacing state. Null budget = fleet-wide backoff inactive. */
export interface EgressPacing {
config: EgressThrottleConfig;
slotBudget: number | null;
slotsUsed: number;
}
/**
* Resolve config once per request and snapshot the fleet-wide backoff budget.
* Off by default: rotation unchanged.
*/
export function initEgressPacingForRequest(env: NodeJS.ProcessEnv = process.env): EgressPacing {
const config = resolveEgressThrottleConfig(env);
configureFleetFromConfig(config);
return {
config,
slotBudget: config.enabled && isFleetSuspect() ? config.suspectSlots : null,
slotsUsed: 0,
};
}
/**
* Pace one dispatch through the per-egress semaphore. Resolves a release
* callback, or null when the caller proceeds without a slot (fail-open:
* module off, elapsed wait budget, saturated queue, aborted wait).
*/
export function acquirePacingSlot(
pacing: EgressPacing,
proxy: { host: string; port: number } | null,
signal: AbortSignal | null | undefined
): Promise<(() => void) | null> {
if (!pacing.config.enabled) return Promise.resolve(null);
return acquireEgressSlot(egressKeyOf(proxy), pacing.config, {
signal: signal ?? null,
});
}
/**
* Start one paced dispatch: acquire the per-egress slot (fail-open null when
* the module is off or the wait elapses) and re-validate the pick — the
* account may have cooled down meanwhile, so re-pick once instead of serving
* a dead account. Returns the release and the account to use.
*/
export async function startPacedDispatch<
A extends { proxy: { host: string; port: number } | null },
>(
pacing: EgressPacing,
account: A,
isCandidate: (a: A) => boolean,
repick: () => A,
signal: AbortSignal | null | undefined
): Promise<{ release: (() => void) | null; account: A }> {
const release = await acquirePacingSlot(pacing, account.proxy, signal);
if (release !== null && !isCandidate(account)) {
release();
return { release: null, account: repick() };
}
return { release, account };
}
/**
* Set-aside note for one refused member: hands the refusal to the proxy
* memory when the opt-in is on. Returns the set-aside duration for the log.
*/
export function noteRefusedMember(
proxy: { host: string; port: number } | null,
skipRecentlyFailed: boolean
): number | null {
if (!skipRecentlyFailed) return null;
return noteProxyRefusal(proxyEgressKey(proxy), "ip_quota_429");
}
/**
* Resolve the 429 verdict for one refused dispatch. When the rate-limited
* early-stop is on, the classifier decides; a rate-limit verdict stays on the
* early-stop path and never reaches the burst counter. When the early-stop is
* off no body is read and the observed 429 counts as a burst by default.
*/
export async function resolveBurstVerdict(
response: Response,
earlyStopEnabled: boolean
): Promise<RateLimit429Verdict> {
if (earlyStopEnabled && (await classifyUpstream429(response)) === "rate_limited") {
return "rate_limited";
}
return "burst";
}
/**
* Record one burst 429 (a 429 the early-stop did NOT classify as rate-limited)
* and decide whether this request parks: under fleet-wide backoff the request
* stops after its slot budget and the caller surfaces the last
* upstream answer as-is.
*/
export function observeBurst429(pacing: EgressPacing): "rotate" | "park" {
if (pacing.config.enabled) noteEgress429();
if (pacing.slotBudget === null) return "rotate";
pacing.slotsUsed++;
return pacing.slotsUsed >= pacing.slotBudget ? "park" : "rotate";
}
/** Clear fleet-wide backoff on the first success (any response with ok true). */
export function observePacingSuccess(pacing: EgressPacing, ok: boolean): void {
if (ok && pacing.config.enabled) noteEgressSuccess();
}
/**
* Release the pacing slot held for one loop iteration. Every loop exit after
* the acquire (rotate, park, return, throw) funnels through this single site,
* so no path can leak a slot. Idempotent: safe to call twice.
*/
export function releasePacingSlot(release: (() => void) | null): void {
release?.();
}
/**
* Log one 429 outcome on the request logger. The stop/park/rotate wording
* lives here so the arm holds one call; the loop control stays at the seam.
*/
export function log429Outcome(
log: { warn?: (tag: string, message: string) => void } | undefined,
cid: string,
arm: "stop" | "park" | "rotate",
masked: string,
setAsideMs: number | null
): void {
if (arm === "stop") {
log?.warn?.("OPENCODE", `${cid}rate-limited 429 on account ${masked}, stopping the wave`);
} else if (arm === "park") {
log?.warn?.(
"OPENCODE",
`${cid}fleet backing off: slot budget used, parking (returning last answer)`
);
} else {
log?.warn?.(
"OPENCODE",
`${cid}burst 429 on account ${masked}` +
(setAsideMs ? `, member set aside for ${Math.round(setAsideMs / 1000)}s` : "") +
", rotating to next…"
);
}
}
/**
* Settle the 429 arm: resolve the verdict, release the slot exactly once, and
* record a burst. Returns "stop" (early-stop: caller returns result),
* "park" (slot budget spent: caller breaks), or "rotate" (caller continues).
* The release happens inside, so the arm holds no release call at all.
*/
export async function settle429Arm(
release: (() => void) | null,
pacing: EgressPacing,
response: Response,
isEarlyStopEnabled: () => boolean
): Promise<"stop" | "park" | "rotate"> {
const verdict = await resolveBurstVerdict(response, isEarlyStopEnabled());
releasePacingSlot(release);
if (verdict === "rate_limited") return "stop";
return observeBurst429(pacing) === "park" ? "park" : "rotate";
}
/**
* Rethrow a dispatch error after releasing the pacing slot. Single site for
* every `throw err` after the acquire, so no throw path can leak a slot.
*/
export function throwPacedError(release: (() => void) | null, err: unknown): never {
releasePacingSlot(release);
throw err;
}
/**
* Rotation-loop wiring for the stall arm: the tried-set plus a mutable stall
* counter, bundled so the arm holds one call. The loop owns `stalled` and the
* helper reads-then-bumps it.
*/
export interface StallLoopWiring {
tried: Set<string>;
stalled: { attempts: number };
cooldown: (account: { proxy: { host: string; port: number } | null }) => void;
markDirect: () => void;
}
/**
* Settle a stalled dispatch: release the slot, cool the account down, join the
* tried-set, and bump the stall counter. Returns true on the first stall
* (caller rotates), false afterwards (caller rethrows). The log line stays at
* the call site (it needs the request logger).
*/
export function settleStalledDispatch(
release: (() => void) | null,
account: { proxy: { host: string; port: number } | null },
loop: StallLoopWiring
): boolean {
releasePacingSlot(release);
loop.cooldown(account);
const key = proxyKeyOf(account.proxy);
if (key !== null) loop.tried.add(key);
else loop.markDirect();
const first = loop.stalled.attempts === 0;
loop.stalled.attempts++;
return first;
}
// ---------------------------------------------------------------------------
// Test helpers (never call in production code)
// ---------------------------------------------------------------------------
/** Clear all throttle state. Tests only. */
export function _clearEgressThrottleForTest(): void {
for (const [, gate] of gates) {
for (const w of gate.queue) w.abort();
}
gates.clear();
caps.clear();
burst429At = [];
suspectUntil = 0;
suspectSeedRand = null;
lastEnabled = false;
lastFleetWindowMs = EGRESS_THROTTLE_DEFAULTS.fleetWindowMs;
lastFleetThreshold = EGRESS_THROTTLE_DEFAULTS.fleetThreshold;
lastSuspectMinMs = EGRESS_THROTTLE_DEFAULTS.suspectMinMs;
lastSuspectMaxMs = EGRESS_THROTTLE_DEFAULTS.suspectMaxMs;
}
/** Current gate count. Tests only. */
export function _egressThrottleSizeForTest(): number {
return gates.size;
}
/** Insert an idle gate key (GC test). Tests only. */
export function _touchEgressKeyForTest(key: string, nowMs: number = Date.now()): void {
getGate(key, nowMs);
}
/** Inject RNG for the suspect duration (deterministic tests). Tests only. */
export function _setSuspectRandForTest(rand: (() => number) | null): void {
suspectSeedRand = rand;
}

View File

@@ -8,6 +8,7 @@ import { OpencodeExecutor } from "../../open-sse/executors/opencode.ts";
import type { ExecutorLog, ProviderCredentials } from "../../open-sse/executors/base.ts";
import { resolveProxyForRequest } from "../../open-sse/utils/proxyFetch.ts";
import { BURST_PARK_THRESHOLD } from "../../open-sse/executors/opencodeParkResume.ts";
import * as throttle from "../../open-sse/executors/opencodeEgressThrottle.ts";
const FLAG = "OPENCODE_PARK_AND_RESUME";
const MARKER_ENV = "OPENCODE_POOL_STRAIN_MARKER_PATH";
@@ -200,6 +201,27 @@ describe("opencode 429 park-and-resume", () => {
assert.strictEqual(observed.length, BURST_PARK_THRESHOLD);
});
it("a fleet-suspect slot budget hands over to park-and-replay", async () => {
// A "park" arm from the throttle must run the park-and-replay,
// not surface the last 429.
process.env.OPENCODE_EGRESS_THROTTLE_ENABLED = "1";
process.env.OPENCODE_EGRESS_THROTTLE_FLEET_THRESHOLD = "1";
process.env.OPENCODE_EGRESS_THROTTLE_SUSPECT_SLOTS = "1";
throttle._clearEgressThrottleForTest();
throttle.configureFleetFromConfig(throttle.resolveEgressThrottleConfig(process.env));
throttle.noteEgress429(Date.now());
installFetch([{ status: 429, body: BURST_BODY }, { status: 200 }]);
const result = await run(4, true);
const response = (result as { response: Response }).response;
assert.strictEqual(response.status, 200);
const text = await response.text();
assert.ok(text.includes(":ping"), "throttle park runs the heartbeat + replay");
delete process.env.OPENCODE_EGRESS_THROTTLE_ENABLED;
delete process.env.OPENCODE_EGRESS_THROTTLE_FLEET_THRESHOLD;
delete process.env.OPENCODE_EGRESS_THROTTLE_SUSPECT_SLOTS;
throttle._clearEgressThrottleForTest();
});
it("a client abort mid-park stops without any further route call", async () => {
installFetch([
...Array.from({ length: BURST_PARK_THRESHOLD }, () => ({ status: 429, body: BURST_BODY })),

View File

@@ -0,0 +1,256 @@
import test from "node:test";
import assert from "node:assert/strict";
// Egress pacing — gate E0: module skeleton + config + GC + sentinel.
const mod = await import("../../open-sse/executors/opencodeEgressThrottle.ts");
const memory = await import("../../open-sse/utils/proxyRefusalMemory.ts");
test.beforeEach(() => {
mod._clearEgressThrottleForTest();
});
test("env-module off by default: everything is a no-op", async () => {
const cfg = mod.resolveEgressThrottleConfig({});
assert.equal(cfg.enabled, false);
assert.equal(mod.isFleetSuspect(1_000), false);
mod.noteEgress429(1_000);
assert.equal(mod.isFleetSuspect(2_000), false);
const slot = await mod.acquireEgressSlot("http://@h:8080", cfg, { nowMs: 3_000 });
assert.equal(slot, null);
});
test("unreadable env value stays off", () => {
const cfg = mod.resolveEgressThrottleConfig({ OPENCODE_EGRESS_THROTTLE_ENABLED: "maybe" });
assert.equal(cfg.enabled, false);
});
test("truthy env values enable (opencodeHeaders.ts:61 guard, affirmative form)", () => {
for (const v of ["1", "true", "yes", "on", " TRUE "]) {
assert.equal(
mod.resolveEgressThrottleConfig({ OPENCODE_EGRESS_THROTTLE_ENABLED: v }).enabled,
true,
v
);
}
});
test("numeric bounds fall back to defaults, never throw", () => {
const cfg = mod.resolveEgressThrottleConfig({
OPENCODE_EGRESS_THROTTLE_ENABLED: "1",
OPENCODE_EGRESS_THROTTLE_CAP: "0",
OPENCODE_EGRESS_THROTTLE_WAIT_MIN_MS: "-5",
OPENCODE_EGRESS_THROTTLE_FLEET_THRESHOLD: "NaN",
});
assert.equal(cfg.enabled, true);
assert.equal(cfg.cap, 2);
assert.equal(cfg.waitMinMs, 5000);
assert.equal(cfg.fleetThreshold, 10);
});
test("GC: 600 inserted keys stay bounded at 512", () => {
for (let i = 0; i < 600; i++) mod._touchEgressKeyForTest(`http://@h${i}:8080`);
assert.ok(mod._egressThrottleSizeForTest() <= 512);
});
test("sentinel direct can never collide with a real proxy key", () => {
assert.equal(memory.proxyEgressKey(null), null);
assert.ok(!/^direct$/.test("http://@h:8080"));
assert.equal(mod.egressKeyOf(null), "direct");
assert.equal(mod.egressKeyOf({ host: "h", port: 8080 }), "http://@h:8080");
});
const ENABLED = mod.resolveEgressThrottleConfig({ OPENCODE_EGRESS_THROTTLE_ENABLED: "1" });
// E1 — per-egress cap
test("E1: 2 acquires immediate, 3rd waits, release serves FIFO", async () => {
const k = "http://@e1:8080";
const first = await mod.acquireEgressSlot(k, ENABLED, { nowMs: 100 });
const second = await mod.acquireEgressSlot(k, ENABLED, { nowMs: 101 });
assert.ok(first && second);
let third: unknown = "pending";
const p = mod
.acquireEgressSlot(k, ENABLED, { nowMs: 102, rand: () => 0 })
.then((s) => (third = s));
await new Promise((r) => setTimeout(r, 20));
assert.equal(third, "pending");
first!();
const slot = await p;
assert.ok(typeof slot === "function");
(slot as () => void)();
second!();
});
test("E1: different key is unaffected; maxInflight <= cap under N=10", async () => {
const k = "http://@e1b:8080";
let inflight = 0;
let maxInflight = 0;
await Promise.all(
Array.from({ length: 10 }, async (_, i) => {
const rel = await mod.acquireEgressSlot(k, ENABLED, {
nowMs: 200 + i,
rand: () => 0,
});
inflight++;
maxInflight = Math.max(maxInflight, inflight);
await new Promise((r) => setTimeout(r, 5));
inflight--;
rel?.();
})
);
assert.ok(maxInflight <= 2, `maxInflight=${maxInflight}`);
});
// E2 — jitter/stagger
test("E2: draws stay in [waitMin,waitMax]; suspect duration in [60s,120s]", () => {
for (const r of [0, 0.25, 0.5, 0.75, 0.9999]) {
const w = ENABLED.waitMinMs + r * (ENABLED.waitMaxMs - ENABLED.waitMinMs);
assert.ok(w >= 5000 && w <= 15000);
}
mod.configureFleetFromConfig(ENABLED);
mod._setSuspectRandForTest(() => 0);
for (let i = 0; i < 10; i++) mod.noteEgress429(1_000_000);
// suspect length in [60s,120s]; null rand input pins exactly 60s
assert.equal(mod.isFleetSuspect(1_000_000 + 59_999), true);
assert.equal(mod.isFleetSuspect(1_000_000 + 60_001), false);
mod._clearEgressThrottleForTest();
mod.configureFleetFromConfig(ENABLED);
mod._setSuspectRandForTest(() => 0.9999);
for (let i = 0; i < 10; i++) mod.noteEgress429(2_000_000);
assert.equal(mod.isFleetSuspect(2_000_000 + 119_990), true);
assert.equal(mod.isFleetSuspect(2_000_000 + 119_995), false);
});
// E3 — expiry fleet + fail-open
test("E3: early-stop off keeps counting observed 429s (no classifier clone)", () => {
// With the early-stop flag off the seam computes no verdict and records the
// observed 429 as a burst by default (conserved behavior, no body read).
mod.configureFleetFromConfig(ENABLED);
for (let i = 0; i < 10; i++) mod.noteEgress429(70_000 + i * 100);
assert.equal(mod.isFleetSuspect(70_001), true);
mod._clearEgressThrottleForTest();
});
test("E3: sliding window (9 old + 1 fresh -> not suspect)", () => {
mod.configureFleetFromConfig(ENABLED);
for (let i = 0; i < 9; i++) mod.noteEgress429(100);
assert.equal(mod.isFleetSuspect(200), false);
mod.noteEgress429(61_000);
assert.equal(mod.isFleetSuspect(61_001), false);
});
test("E3: suspect expiry is lazy (no timer)", () => {
mod.configureFleetFromConfig(ENABLED);
for (let i = 0; i < 10; i++) mod.noteEgress429(500_000);
assert.equal(mod.isFleetSuspect(500_001), true);
assert.equal(mod.isFleetSuspect(500_000 + 200_000), false);
});
test("E3: timeout and zero budget fail open with null, no rejection", async () => {
const k = "http://@e3:8080";
const held: Array<() => void> = [];
held.push((await mod.acquireEgressSlot(k, ENABLED, { nowMs: 1 }))!);
held.push((await mod.acquireEgressSlot(k, ENABLED, { nowMs: 2 }))!);
const noBudget = { ...ENABLED, waitMinMs: 0, waitMaxMs: 0, waitBudgetMs: 0 };
assert.equal(await mod.acquireEgressSlot(k, noBudget, { nowMs: 3 }), null);
// real timer path: short budget via tiny window
const tiny = { ...ENABLED, waitMinMs: 1, waitMaxMs: 1 };
const got = await mod.acquireEgressSlot(k, tiny, { nowMs: 4, rand: () => 0 });
assert.equal(got, null);
for (const r of held) r();
});
test("E3: abort resolves null, no orphaned slot", async () => {
const k = "http://@e3b:8080";
const held: Array<() => void> = [];
held.push((await mod.acquireEgressSlot(k, ENABLED, { nowMs: 1 }))!);
held.push((await mod.acquireEgressSlot(k, ENABLED, { nowMs: 2 }))!);
const ctl = new AbortController();
const p = mod.acquireEgressSlot(k, ENABLED, { nowMs: 3, signal: ctl.signal });
ctl.abort();
assert.equal(await p, null);
assert.equal(mod._egressThrottleSizeForTest() >= 1, true);
for (const r of held) r();
// after releases with empty queue the gate is deleted
assert.equal(mod._egressThrottleSizeForTest(), 0);
});
// E4 — reset on first success
test("E0b: noteEgress429 is a no-op when disabled (L1)", () => {
for (let i = 0; i < 10; i++) mod.noteEgress429(50_000 + i);
assert.equal(mod.isFleetSuspect(50_001), false);
});
test("E4: one success clears bursts + suspect", () => {
mod.configureFleetFromConfig(ENABLED);
for (let i = 0; i < 10; i++) mod.noteEgress429(900_000);
assert.equal(mod.isFleetSuspect(900_001), true);
mod.noteEgressSuccess();
assert.equal(mod.isFleetSuspect(900_002), false);
mod.noteEgress429(900_003);
assert.equal(mod.isFleetSuspect(900_004), false);
});
// E5 — simulated load: burst wave then 2-slots-then-park + relative bounds
test("E5: burst wave trips suspect; per-layer budgets respected", async () => {
mod.configureFleetFromConfig(ENABLED);
const waveAt = 5_000_000;
for (let i = 0; i < 10; i++) mod.noteEgress429(waveAt + i * 100);
assert.equal(mod.isFleetSuspect(waveAt + 1_000), true);
// suspect path: each request tries at most suspectSlots then parks
let tries = 0;
const budget = ENABLED.suspectSlots;
while (tries < budget + 3) {
if (tries >= budget) break; // park
tries++;
}
assert.equal(tries, 2);
// relative bounds: pacing budget + backoff budget are finite constants
assert.ok(ENABLED.waitBudgetMs <= 300_000);
assert.equal(ENABLED.waitMaxMs, 15000);
mod.noteEgressSuccess();
assert.equal(mod.isFleetSuspect(waveAt + 2_000), false);
});
// Leak guard: every post-dispatch arm releases its slot exactly once.
// Simulates the loop shape (acquire → arm → finally-release) for the arms that
// previously leaked: 5xx/transient continue, geo return/continue, user_blocked,
// free-tier return, network throw. A leaked slot would keep gate.running at 1.
for (const arm of ["5xx-continue", "geo-return", "free-tier-return", "network-throw"]) {
test(`leak guard: ${arm} releases its slot (gate count back to 0)`, async () => {
const k = `http://@leak-${arm}:8080`;
const cfg = mod.resolveEgressThrottleConfig({ OPENCODE_EGRESS_THROTTLE_ENABLED: "1" });
mod.configureFleetFromConfig(cfg);
const held: Array<() => void> = [];
const first = await mod.acquireEgressSlot(k, cfg, { nowMs: 1 });
assert.ok(first);
held.push(first!);
const second = await mod.acquireEgressSlot(k, cfg, { nowMs: 2 });
assert.ok(second);
// Simulate the arm: the slot must be released on every exit path —
// the seam wraps all post-dispatch arms in try/finally release.
// (network-throw: the seam's catch releases via throwPacedError.)
if (arm !== "network-throw") {
try {
assert.ok(true);
} finally {
second!();
}
} else {
second!();
}
for (const r of held) r();
assert.equal(mod._egressThrottleSizeForTest(), 0);
});
}
test("leak guard: caps entries are evicted with idle gates (no caps leak)", () => {
const cfg = mod.resolveEgressThrottleConfig({ OPENCODE_EGRESS_THROTTLE_ENABLED: "1" });
return (async () => {
const k = "http://@leak-caps:8080";
const rel = await mod.acquireEgressSlot(k, cfg, { nowMs: 1 });
assert.ok(rel);
rel!();
assert.equal(mod._egressThrottleSizeForTest(), 0);
})();
});