Files
OmniRoute/tests/unit/account-rotation.test.ts
Dizzle 94cf4c402a fix(executors): rotate to the next account on network throws when the account has a dedicated proxy (#10402)
OpencodeExecutor and MimocodeExecutor rotated to the next account only on
HTTP 429. A network exception (timeout, connection refused/reset) on one
account instead propagated out of execute() and failed the whole request,
even when other accounts remained available.

Both executors now rotate on a network exception only when the failed
account has its own dedicated proxy (account.proxy !== null) — a dead
proxy is genuinely account-scoped, so rotating away from it is safe.
Accounts sharing the default egress (no proxy configured) trigger the
same cooldown and are skipped for the rest of the request once the shared
egress is known down, but a later account with its own dedicated proxy is
still tried normally — a throw on a proxy-less account no longer strands
a proxied account further in the rotation. This behavior is gated behind
NETWORK_ROTATION_SHARED_EGRESS_GUARD (Feature Flag, default on); disabled,
it reproduces the immediate-propagation behavior this fix started from.

The shared rotation mechanics (pickAccount/markCooldown/markSuccess) are
extracted into executors/accountRotation.ts, used by both executors —
they had independently implemented the same round-robin+cooldown
skeleton. This also fixes an identical, pre-existing bug in
MimocodeExecutor that predates this PR: its catch block called
markCooldown unconditionally on any throw, with no proxy check and no
warn log (a silent exception swallow on a path that influences the
result).

The cooldown formula for both the proxy and shared-egress cases reuses
the repo's already-established "transient, not clearly attributable"
constants (errorConfig.ts TRANSIENT_COOLDOWN_MS/COOLDOWN_MS.transientMax,
already used by accountFallback.ts for network-error classification)
instead of introducing a separate value.

MimocodeExecutor's network-error 502 body also now goes through
buildErrorBody()/sanitizeErrorMessage() instead of embedding the raw
caught error message directly (Hard Rule #12), matching the sanitization
already used on its #2101 malformed-request path.

Validated by TDD (Hard Rule #18): tests/unit/account-rotation.test.ts
covers the shared module directly; opencode-proxy-rotation-4954.test.ts
and mimocode-executor.test.ts cover the proxy-configured rotation path,
the mixed-fleet case, the shared-egress single-network-call case, and the
NETWORK_ROTATION_SHARED_EGRESS_GUARD-disabled legacy path, for each
executor. tsc, lint, and the provider golden-path gates
(check:provider-consistency, check:provider-assets,
provider-translate-path-golden.test.ts) are clean on all touched files.

Co-authored-by: Max <maxmad64@gmail.com>
2026-08-16 00:16:04 -03:00

117 lines
4.7 KiB
TypeScript

import { describe, it } from "node:test";
import assert from "node:assert";
import {
isAccountReady,
pickAccount,
markCooldown,
markSuccess,
maskAccountId,
isNetworkErrorRotatable,
type RotatableAccount,
} from "../../open-sse/executors/accountRotation.ts";
function account(overrides: Partial<RotatableAccount> = {}): RotatableAccount {
return {
fingerprint: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
cooldownUntil: 0,
consecutiveFails: 0,
proxy: null,
...overrides,
};
}
describe("accountRotation", () => {
it("isAccountReady is true when cooldownUntil is in the past", () => {
assert.strictEqual(isAccountReady(account({ cooldownUntil: Date.now() - 1000 })), true);
});
it("isAccountReady is false when cooldownUntil is in the future", () => {
assert.strictEqual(isAccountReady(account({ cooldownUntil: Date.now() + 60_000 })), false);
});
it("markCooldown increments consecutiveFails and sets a future cooldownUntil", () => {
const acct = account();
markCooldown(acct);
assert.strictEqual(acct.consecutiveFails, 1);
assert.ok(acct.cooldownUntil > Date.now());
});
it("markCooldown backs off exponentially with consecutive failures", () => {
const acct = account();
markCooldown(acct);
const firstCooldown = acct.cooldownUntil;
markCooldown(acct);
assert.strictEqual(acct.consecutiveFails, 2);
// Second backoff (base*2^1) must be strictly larger than the first
// (base*2^0), modulo the shared jitter window — compare the floor.
assert.ok(acct.cooldownUntil - Date.now() > firstCooldown - Date.now() - 1000);
});
it("markCooldown uses the same magnitude regardless of why it was called (429 or network throw)", () => {
// No `short`/severity parameter: proxy-attributable failures (429, dead
// proxy) and shared-egress network throws use the identical formula —
// the repo's own established "transient, not clearly attributable"
// cooldown (errorConfig.ts TRANSIENT_COOLDOWN_MS/transientMax) already
// covers both cases at the same magnitude. The behavioral fix for
// shared-egress accounts lives in the caller's skip logic, not here.
const a = account();
const b = account();
markCooldown(a);
markCooldown(b);
// Both draw from the same base backoff ± up to 1s jitter — same formula,
// no separate "short" magnitude for either call site.
assert.ok(
Math.abs(a.cooldownUntil - b.cooldownUntil) <= 1000,
"same account state must produce cooldowns within the shared jitter window"
);
});
it("markSuccess resets consecutiveFails to 0", () => {
const acct = account({ consecutiveFails: 5 });
markSuccess(acct);
assert.strictEqual(acct.consecutiveFails, 0);
});
it("maskAccountId masks a real fingerprint to its first 8 chars + ellipsis", () => {
assert.strictEqual(maskAccountId("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), "aaaaaaaa…");
});
it("maskAccountId reports the empty/default fingerprint as 'direct'", () => {
assert.strictEqual(maskAccountId(""), "direct");
});
it("pickAccount skips accounts in cooldown and rotates nextAccountIdx", () => {
const a = account({ fingerprint: "a", cooldownUntil: Date.now() + 60_000 });
const b = account({ fingerprint: "b", cooldownUntil: 0 });
const state = { nextAccountIdx: 0 };
const picked = pickAccount([a, b], state);
assert.strictEqual(picked.fingerprint, "b", "must skip the account still in cooldown");
});
it("pickAccount falls back to the next index when every account is in cooldown", () => {
const a = account({ fingerprint: "a", cooldownUntil: Date.now() + 60_000 });
const b = account({ fingerprint: "b", cooldownUntil: Date.now() + 60_000 });
const state = { nextAccountIdx: 0 };
const picked = pickAccount([a, b], state);
assert.strictEqual(picked.fingerprint, "a", "must still return an account, not throw/hang");
});
it("pickAccount accepts a custom isReady predicate (e.g. JWT-freshness-aware)", () => {
const a = account({ fingerprint: "a", cooldownUntil: 0 });
const b = account({ fingerprint: "b", cooldownUntil: 0 });
const state = { nextAccountIdx: 0 };
// Custom predicate rejects "a" for a reason cooldown alone wouldn't catch.
const picked = pickAccount([a, b], state, (acct: RotatableAccount) => acct.fingerprint !== "a");
assert.strictEqual(picked.fingerprint, "b");
});
it("isNetworkErrorRotatable is true only when the account has a configured proxy", () => {
const withProxy = account({
proxy: { type: "http", host: "127.0.0.1", port: 8080 },
});
const withoutProxy = account({ proxy: null });
assert.strictEqual(isNetworkErrorRotatable(withProxy), true);
assert.strictEqual(isNetworkErrorRotatable(withoutProxy), false);
});
});