mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-17 20:52:15 +03:00
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>
117 lines
4.7 KiB
TypeScript
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);
|
|
});
|
|
});
|