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>
110 lines
4.3 KiB
TypeScript
110 lines
4.3 KiB
TypeScript
/**
|
|
* Shared multi-account rotation mechanics for noauth executors that round-robin
|
|
* across several "accounts" (fingerprints), each with an optional dedicated
|
|
* proxy — currently `OpencodeExecutor` and `MimocodeExecutor`.
|
|
*
|
|
* Extracted after both executors independently implemented the same
|
|
* pickAccount/markCooldown/markSuccess skeleton with the same exponential
|
|
* backoff, and independently needed the same fix for the same latent bug (a
|
|
* network exception was treated as account-scoped rotation fodder even for
|
|
* accounts sharing the default egress — see `isNetworkErrorRotatable`).
|
|
*/
|
|
|
|
// Reuses the repo's established "transient, not clearly attributable" failure
|
|
// cooldown (already used by accountFallback.ts for network-error dedup, see
|
|
// its "one transient blip opens the whole-provider breaker" comment) instead
|
|
// of inventing a separate constant — same magnitude the codebase already
|
|
// applies whether the failure is a 429 or a network-level throw.
|
|
import { TRANSIENT_COOLDOWN_MS, COOLDOWN_MS } from "../config/errorConfig.ts";
|
|
|
|
/** Per-account proxy configuration, persisted by NoAuthAccountCard under
|
|
* `providerSpecificData.accountProxies` (keyed by the account id, which the UI
|
|
* stores in `providerSpecificData.fingerprints`). */
|
|
export interface AccountProxyConfig {
|
|
fingerprint: string;
|
|
proxy: {
|
|
type: string;
|
|
host: string;
|
|
port: number;
|
|
username?: string;
|
|
password?: string;
|
|
relayAuth?: string;
|
|
} | null;
|
|
}
|
|
|
|
/** The subset of per-account state the rotation mechanics need. Executors may
|
|
* carry additional fields (e.g. mimocode's `jwt`/`expiresAt`) — this is the
|
|
* minimum shape `pickAccount`/`markCooldown`/`markSuccess` operate on. */
|
|
export interface RotatableAccount {
|
|
fingerprint: string;
|
|
cooldownUntil: number;
|
|
consecutiveFails: number;
|
|
proxy: AccountProxyConfig["proxy"];
|
|
}
|
|
|
|
const COOLDOWN_BASE_MS = TRANSIENT_COOLDOWN_MS;
|
|
const COOLDOWN_MAX_MS = COOLDOWN_MS.transientMax;
|
|
|
|
export function isAccountReady(account: RotatableAccount): boolean {
|
|
return account.cooldownUntil <= Date.now();
|
|
}
|
|
|
|
/** Round-robin pick, skipping accounts not `isReady`; falls back to the next
|
|
* index (even if not ready) so a caller always gets an account rather than
|
|
* hanging when every account is unavailable. Mutates `state.nextAccountIdx`.
|
|
*
|
|
* `isReady` defaults to the plain cooldown check (`isAccountReady`); pass a
|
|
* custom predicate when readiness depends on more than cooldown (e.g.
|
|
* mimocode's JWT-freshness-aware variant). */
|
|
export function pickAccount<T extends RotatableAccount>(
|
|
accounts: T[],
|
|
state: { nextAccountIdx: number },
|
|
isReady: (account: T) => boolean = isAccountReady
|
|
): T {
|
|
for (let i = 0; i < accounts.length; i++) {
|
|
const idx = (state.nextAccountIdx + i) % accounts.length;
|
|
const acct = accounts[idx];
|
|
if (isReady(acct)) {
|
|
state.nextAccountIdx = (idx + 1) % accounts.length;
|
|
return acct;
|
|
}
|
|
}
|
|
const fallbackIdx = state.nextAccountIdx % accounts.length;
|
|
state.nextAccountIdx = (state.nextAccountIdx + 1) % accounts.length;
|
|
return accounts[fallbackIdx];
|
|
}
|
|
|
|
export function markCooldown(account: RotatableAccount): void {
|
|
account.consecutiveFails++;
|
|
const backoff = Math.min(
|
|
COOLDOWN_BASE_MS * Math.pow(2, account.consecutiveFails - 1),
|
|
COOLDOWN_MAX_MS
|
|
);
|
|
account.cooldownUntil = Date.now() + backoff + Math.random() * 1000;
|
|
}
|
|
|
|
export function markSuccess(account: RotatableAccount): void {
|
|
account.consecutiveFails = 0;
|
|
}
|
|
|
|
/** Mask an account id for logs (UI calls it a fingerprint). */
|
|
export function maskAccountId(fingerprint: string): string {
|
|
if (!fingerprint) return "direct";
|
|
return `${fingerprint.slice(0, 8)}…`;
|
|
}
|
|
|
|
/**
|
|
* Whether a network exception (timeout, connection refused/reset) on this
|
|
* account should trigger rotation to the next account, vs propagating.
|
|
*
|
|
* Only true when the account has its own egress (a configured proxy) — that's
|
|
* the case a dead/unreachable proxy genuinely justifies rotating away from.
|
|
* Accounts sharing the default egress (no proxy) can all fail at once on a
|
|
* real network outage: rotating there would just retry the same failure
|
|
* against every account while poisoning each one's cooldown for a cause that
|
|
* isn't theirs.
|
|
*/
|
|
export function isNetworkErrorRotatable(account: RotatableAccount): boolean {
|
|
return account.proxy !== null;
|
|
}
|