mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 23:02:10 +03:00
* fix(sse): honor per-account proxies and fingerprint rotation in opencode executor (#4954) * chore(quality): rebaseline auth.ts file-size for #4954 (+39: synthetic no-auth providerSpecificData hydration of fingerprints/accountProxies; irreducible credential-path wiring, covered by opencode-proxy-rotation-4954.test.ts + 159 auth/noauth regression) --------- Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>
This commit is contained in:
committed by
GitHub
parent
bf6c8ca3fd
commit
083d4b0044
@@ -13,7 +13,7 @@ _In development — bullets added per PR; finalized at release._
|
||||
### 🔧 Bug Fixes
|
||||
|
||||
- **fix(dashboard):** show custom provider given-name instead of internal id across dashboard pages — cache, combo health, compression analytics, cost overview, health/autopilot, provider stats, route explainability, provider utilization, runtime. Adds shared `resolveProviderName` resolver and `useProviderNodeMap` hook. (#4603)
|
||||
- **fix(compression):** stop RTK over-truncating file-read tool results — a tool returning a file's contents (e.g. a ~147-line code/prose file via a Read tool) is no longer head/tail-truncated by the generic-output fallback filter or the line/char hard-cap, which were silently dropping the middle. RTK now treats content with no detected command, an `unknown` type, and no error markers as a document read and skips those truncation paths; genuine repetitive command output (npm install, make, docker logs) is unaffected. (#4559)
|
||||
- **fix(sse):** honor per-account proxies and fingerprint rotation in the OpenCode (Free) executor. The UI exposes multi-account + per-account proxy controls, but the executor was a plain pass-through — requests always egressed direct and never rotated. The executor now reads `providerSpecificData.accountProxies`, dispatches each request through the selected account's proxy via `runWithProxyContext`, and rotates to the next account (with exponential cooldown) on a 429. The synthetic no-auth credentials are also hydrated with the connection's `fingerprints`/`accountProxies` so the config reaches the executor (also fixes the same gap for MiMoCode). (#4954)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -227,7 +227,7 @@
|
||||
"src/shared/services/cliRuntime.ts": 1090,
|
||||
"src/shared/validation/schemas.ts": 2523,
|
||||
"src/sse/handlers/chat.ts": 1525,
|
||||
"src/sse/services/auth.ts": 2289
|
||||
"src/sse/services/auth.ts": 2328
|
||||
},
|
||||
"testCap": 800,
|
||||
"testFrozen": {
|
||||
|
||||
@@ -11,18 +11,177 @@ import {
|
||||
injectReasoningContentForThinkingModel,
|
||||
isThinkingMessageModel,
|
||||
} from "../utils/reasoningContentInjector.ts";
|
||||
import { runWithProxyContext } from "../utils/proxyFetch.ts";
|
||||
|
||||
/**
|
||||
* Per-account proxy configuration, persisted by NoAuthAccountCard under
|
||||
* `providerSpecificData.accountProxies` (keyed by the account id, which the UI
|
||||
* stores in `providerSpecificData.fingerprints`). Same shape mimocode uses.
|
||||
*/
|
||||
export interface OpencodeAccountProxyConfig {
|
||||
fingerprint: string;
|
||||
proxy: {
|
||||
type: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
/** Runtime rotation/cooldown state for one "OpenCode Free" account. */
|
||||
interface OpencodeAccountState {
|
||||
/** Account id (UI: providerSpecificData.fingerprints[i]); "" for the default direct account. */
|
||||
fingerprint: string;
|
||||
cooldownUntil: number;
|
||||
consecutiveFails: number;
|
||||
/** Resolved proxy config for this account (null = direct egress). */
|
||||
proxy: OpencodeAccountProxyConfig["proxy"];
|
||||
}
|
||||
|
||||
const OPENCODE_COOLDOWN_BASE_MS = 5_000;
|
||||
const OPENCODE_COOLDOWN_MAX_MS = 60_000;
|
||||
|
||||
export class OpencodeExecutor extends BaseExecutor {
|
||||
_requestFormat: string | null = null;
|
||||
|
||||
/**
|
||||
* Per-account rotation state, rebuilt from credentials on each request. The
|
||||
* default entry (fingerprint "") represents the single anonymous account with
|
||||
* no configured proxy — preserves the historical direct pass-through when the
|
||||
* user has not configured any per-account proxy.
|
||||
*/
|
||||
private accounts: OpencodeAccountState[] = [
|
||||
{ fingerprint: "", cooldownUntil: 0, consecutiveFails: 0, proxy: null },
|
||||
];
|
||||
private nextAccountIdx = 0;
|
||||
|
||||
constructor(provider: string) {
|
||||
super(provider, PROVIDERS[provider] || PROVIDERS.openai);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild `accounts` from `providerSpecificData.fingerprints` +
|
||||
* `providerSpecificData.accountProxies`. Each configured account id becomes a
|
||||
* rotation slot carrying its own proxy. When the user configured no accounts
|
||||
* at all, the single default direct account is kept (backward compatible).
|
||||
*/
|
||||
private syncAccountsFromCredentials(credentials: ProviderCredentials): void {
|
||||
const psd = credentials?.providerSpecificData;
|
||||
const fingerprints = Array.isArray(psd?.fingerprints)
|
||||
? (psd!.fingerprints as unknown[]).filter((f): f is string => typeof f === "string")
|
||||
: [];
|
||||
|
||||
const accountProxies = psd?.accountProxies as OpencodeAccountProxyConfig[] | undefined;
|
||||
const proxyMap = Array.isArray(accountProxies)
|
||||
? new Map(accountProxies.map((ap) => [ap.fingerprint, ap.proxy ?? null] as const))
|
||||
: null;
|
||||
|
||||
if (fingerprints.length === 0) {
|
||||
// No configured accounts — keep a single direct account.
|
||||
this.accounts = [{ fingerprint: "", cooldownUntil: 0, consecutiveFails: 0, proxy: null }];
|
||||
this.nextAccountIdx = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const previous = new Map(this.accounts.map((a) => [a.fingerprint, a] as const));
|
||||
this.accounts = fingerprints.map((fp) => {
|
||||
const prior = previous.get(fp);
|
||||
return {
|
||||
fingerprint: fp,
|
||||
cooldownUntil: prior?.cooldownUntil ?? 0,
|
||||
consecutiveFails: prior?.consecutiveFails ?? 0,
|
||||
proxy: proxyMap ? (proxyMap.get(fp) ?? null) : null,
|
||||
};
|
||||
});
|
||||
if (this.nextAccountIdx >= this.accounts.length) this.nextAccountIdx = 0;
|
||||
}
|
||||
|
||||
private isAccountReady(account: OpencodeAccountState): boolean {
|
||||
return account.cooldownUntil <= Date.now();
|
||||
}
|
||||
|
||||
/** Round-robin pick, skipping accounts in cooldown; falls back to the next index. */
|
||||
private pickAccount(): OpencodeAccountState {
|
||||
for (let i = 0; i < this.accounts.length; i++) {
|
||||
const idx = (this.nextAccountIdx + i) % this.accounts.length;
|
||||
const acct = this.accounts[idx];
|
||||
if (this.isAccountReady(acct)) {
|
||||
this.nextAccountIdx = (idx + 1) % this.accounts.length;
|
||||
return acct;
|
||||
}
|
||||
}
|
||||
const fallbackIdx = this.nextAccountIdx % this.accounts.length;
|
||||
this.nextAccountIdx = (this.nextAccountIdx + 1) % this.accounts.length;
|
||||
return this.accounts[fallbackIdx];
|
||||
}
|
||||
|
||||
private markCooldown(account: OpencodeAccountState): void {
|
||||
account.consecutiveFails++;
|
||||
const backoff = Math.min(
|
||||
OPENCODE_COOLDOWN_BASE_MS * Math.pow(2, account.consecutiveFails - 1),
|
||||
OPENCODE_COOLDOWN_MAX_MS
|
||||
);
|
||||
account.cooldownUntil = Date.now() + backoff + Math.random() * 1000;
|
||||
}
|
||||
|
||||
private markSuccess(account: OpencodeAccountState): void {
|
||||
account.consecutiveFails = 0;
|
||||
}
|
||||
|
||||
/** Mask an account id for logs (UI calls it a fingerprint). */
|
||||
private static maskAccountId(fingerprint: string): string {
|
||||
if (!fingerprint) return "direct";
|
||||
return `${fingerprint.slice(0, 8)}…`;
|
||||
}
|
||||
|
||||
async execute(input: ExecuteInput) {
|
||||
this._requestFormat = getModelTargetFormat(this.provider, input.model) || "openai";
|
||||
try {
|
||||
return await super.execute(input);
|
||||
this.syncAccountsFromCredentials(input.credentials);
|
||||
|
||||
const hasProxies = this.accounts.some((a) => a.proxy !== null);
|
||||
// Fast path: no multi-account proxy wiring configured → original behavior.
|
||||
if (this.accounts.length === 1 && !hasProxies) {
|
||||
return await super.execute(input);
|
||||
}
|
||||
|
||||
const { log } = input;
|
||||
let lastResult: Awaited<ReturnType<BaseExecutor["execute"]>> | null = null;
|
||||
|
||||
for (let attempt = 0; attempt < this.accounts.length; attempt++) {
|
||||
const account = this.pickAccount();
|
||||
const masked = OpencodeExecutor.maskAccountId(account.fingerprint);
|
||||
log?.debug?.(
|
||||
"OPENCODE",
|
||||
`dispatch via account ${masked} (idx ${attempt + 1}/${this.accounts.length})` +
|
||||
(account.proxy ? ` through proxy ${account.proxy.host}:${account.proxy.port}` : " direct")
|
||||
);
|
||||
|
||||
// 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 result = await runWithProxyContext(account.proxy, () =>
|
||||
super.execute({ ...input, skipUpstreamRetry: true })
|
||||
);
|
||||
lastResult = result;
|
||||
|
||||
const status = result.response.status;
|
||||
if (status === 429) {
|
||||
this.markCooldown(account);
|
||||
log?.warn?.(
|
||||
"OPENCODE",
|
||||
`Rate limited (429) on account ${masked}, rotating to next…`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.markSuccess(account);
|
||||
return result;
|
||||
}
|
||||
|
||||
// All accounts returned 429 (or errored) — surface the last response.
|
||||
return lastResult ?? (await super.execute(input));
|
||||
} finally {
|
||||
this._requestFormat = null;
|
||||
}
|
||||
|
||||
@@ -781,14 +781,14 @@ type AnonymousFallbackProviderDefinition = {
|
||||
noAuth?: boolean;
|
||||
};
|
||||
|
||||
function buildSyntheticNoAuthCredentials(): {
|
||||
function buildSyntheticNoAuthCredentials(providerSpecificData: JsonRecord = {}): {
|
||||
apiKey: null;
|
||||
accessToken: null;
|
||||
refreshToken: null;
|
||||
expiresAt: null;
|
||||
projectId: null;
|
||||
copilotToken: null;
|
||||
providerSpecificData: Record<string, never>;
|
||||
providerSpecificData: JsonRecord;
|
||||
connectionId: typeof SYNTHETIC_NOAUTH_CONNECTION_ID;
|
||||
testStatus: "active";
|
||||
lastError: null;
|
||||
@@ -809,7 +809,7 @@ function buildSyntheticNoAuthCredentials(): {
|
||||
expiresAt: null,
|
||||
projectId: null,
|
||||
copilotToken: null,
|
||||
providerSpecificData: {},
|
||||
providerSpecificData,
|
||||
connectionId: SYNTHETIC_NOAUTH_CONNECTION_ID,
|
||||
testStatus: "active",
|
||||
lastError: null,
|
||||
@@ -821,6 +821,39 @@ function buildSyntheticNoAuthCredentials(): {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* #4954 — A no-auth provider ("OpenCode Free", MiMoCode, …) has no DB-backed
|
||||
* credential, but its NoAuthAccountCard DOES persist a real connection row whose
|
||||
* `providerSpecificData` carries the per-account proxy/rotation config
|
||||
* (`fingerprints` + `accountProxies`). The synthetic credentials returned above
|
||||
* default to an empty `providerSpecificData`, so without hydration the executor
|
||||
* never sees those proxies and every request egresses direct. Pull just the
|
||||
* rotation-relevant fields off the active connection so the executor can honor
|
||||
* them. Best-effort: any read failure falls back to empty (historical behavior).
|
||||
*/
|
||||
async function loadNoAuthProviderSpecificData(providerId: string): Promise<JsonRecord> {
|
||||
try {
|
||||
const connectionsRaw = await getProviderConnections({ provider: providerId });
|
||||
const connections = (Array.isArray(connectionsRaw) ? connectionsRaw : []).map(
|
||||
toProviderConnection
|
||||
);
|
||||
const hydrated: JsonRecord = {};
|
||||
for (const conn of connections) {
|
||||
const psd = conn.providerSpecificData;
|
||||
if (!psd || typeof psd !== "object") continue;
|
||||
if (Array.isArray(psd.fingerprints) && !Array.isArray(hydrated.fingerprints)) {
|
||||
hydrated.fingerprints = psd.fingerprints;
|
||||
}
|
||||
if (Array.isArray(psd.accountProxies) && !Array.isArray(hydrated.accountProxies)) {
|
||||
hydrated.accountProxies = psd.accountProxies;
|
||||
}
|
||||
}
|
||||
return hydrated;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function providerCanUseSyntheticNoAuthFallback(providerId: string): boolean {
|
||||
const providerDef = getProviderById(providerId) as
|
||||
| AnonymousFallbackProviderDefinition
|
||||
@@ -838,10 +871,16 @@ function providerCanUseSyntheticNoAuthFallback(providerId: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function maybeSyntheticNoAuthFallback(providerId: string, excludedConnectionIds: Set<string>) {
|
||||
async function maybeSyntheticNoAuthFallback(
|
||||
providerId: string,
|
||||
excludedConnectionIds: Set<string>
|
||||
) {
|
||||
if (!providerCanUseSyntheticNoAuthFallback(providerId)) return null;
|
||||
if (excludedConnectionIds.has(SYNTHETIC_NOAUTH_CONNECTION_ID)) return null;
|
||||
return buildSyntheticNoAuthCredentials();
|
||||
// #4954: hydrate per-account proxy/rotation config off the connection row so
|
||||
// no-auth executors (opencode, mimocode) actually honor configured proxies.
|
||||
const providerSpecificData = await loadNoAuthProviderSpecificData(providerId);
|
||||
return buildSyntheticNoAuthCredentials(providerSpecificData);
|
||||
}
|
||||
|
||||
function normalizeExcludedConnectionIds(
|
||||
@@ -1021,7 +1060,7 @@ export async function getProviderCredentials(
|
||||
excludeConnectionId,
|
||||
options.excludeConnectionIds
|
||||
);
|
||||
return maybeSyntheticNoAuthFallback(resolvedId, excludedForNoAuth);
|
||||
return await maybeSyntheticNoAuthFallback(resolvedId, excludedForNoAuth);
|
||||
}
|
||||
|
||||
const allowSuppressedConnections = options.allowSuppressedConnections === true;
|
||||
@@ -1106,7 +1145,7 @@ export async function getProviderCredentials(
|
||||
// the dashboard sees a misleading "bad_request" code.
|
||||
const terminalConnections = allConnections.filter(isTerminalConnectionStatus);
|
||||
if (terminalConnections.length === allConnections.length) {
|
||||
const syntheticFallback = maybeSyntheticNoAuthFallback(resolvedId, excludedConnectionIds);
|
||||
const syntheticFallback = await maybeSyntheticNoAuthFallback(resolvedId, excludedConnectionIds);
|
||||
if (syntheticFallback) return syntheticFallback;
|
||||
|
||||
const statusCounts = new Map<string, number>();
|
||||
@@ -1123,7 +1162,7 @@ export async function getProviderCredentials(
|
||||
};
|
||||
}
|
||||
}
|
||||
const syntheticFallback = maybeSyntheticNoAuthFallback(resolvedId, excludedConnectionIds);
|
||||
const syntheticFallback = await maybeSyntheticNoAuthFallback(resolvedId, excludedConnectionIds);
|
||||
if (syntheticFallback) return syntheticFallback;
|
||||
log.warn("AUTH", `No credentials for ${provider}`);
|
||||
return null;
|
||||
@@ -1294,7 +1333,7 @@ export async function getProviderCredentials(
|
||||
cooldownModel: allBlockedByModelCooldown ? requestedModel : null,
|
||||
};
|
||||
}
|
||||
const syntheticFallback = maybeSyntheticNoAuthFallback(resolvedId, excludedConnectionIds);
|
||||
const syntheticFallback = await maybeSyntheticNoAuthFallback(resolvedId, excludedConnectionIds);
|
||||
if (syntheticFallback) return syntheticFallback;
|
||||
log.warn("AUTH", `${provider} | all ${connections.length} accounts unavailable`);
|
||||
return null;
|
||||
|
||||
168
tests/unit/opencode-proxy-rotation-4954.test.ts
Normal file
168
tests/unit/opencode-proxy-rotation-4954.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { describe, it, beforeEach, afterEach, before, after } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import net from "node:net";
|
||||
import { OpencodeExecutor } from "../../open-sse/executors/opencode.ts";
|
||||
import { resolveProxyForRequest } from "../../open-sse/utils/proxyFetch.ts";
|
||||
|
||||
/**
|
||||
* #4954 — "OpenCode Free" exposes per-account proxy + multi-account rotation in
|
||||
* the UI (NoAuthAccountCard persists providerSpecificData.fingerprints +
|
||||
* providerSpecificData.accountProxies), but the executor ignored them entirely:
|
||||
* every request egressed direct and never rotated. These tests pin the wiring:
|
||||
*
|
||||
* 1. A request for an account that has a configured proxy must egress THROUGH
|
||||
* that proxy (resolveProxyForRequest reports source "context", not "direct").
|
||||
* 2. On a 429 the executor must rotate to the NEXT account (and its proxy).
|
||||
*
|
||||
* The dispatch layer is mocked by stubbing globalThis.fetch — exactly what the
|
||||
* proxy context wraps — and we observe the proxy that resolveProxyForRequest sees
|
||||
* for the in-flight request, mirroring the mimocode proxy integration test. Two
|
||||
* throwaway local TCP listeners stand in for the proxies so runWithProxyContext's
|
||||
* fast-fail reachability probe passes without a live SOCKS/HTTP proxy.
|
||||
*/
|
||||
|
||||
const log = { debug() {}, info() {}, warn() {}, error() {} };
|
||||
|
||||
const ACCOUNT_A = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
const ACCOUNT_B = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
||||
|
||||
// Two local listeners so the proxy fast-fail reachability check succeeds. The
|
||||
// proxy host/port are observable in the egress context — that is what asserts the
|
||||
// per-account proxy is honored (was always "direct" before #4954).
|
||||
let serverA: net.Server;
|
||||
let serverB: net.Server;
|
||||
let portA = 0;
|
||||
let portB = 0;
|
||||
|
||||
function listen(server: net.Server): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
resolve((server.address() as net.AddressInfo).port);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
serverA = net.createServer((s) => s.destroy());
|
||||
serverB = net.createServer((s) => s.destroy());
|
||||
portA = await listen(serverA);
|
||||
portB = await listen(serverB);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
serverA?.close();
|
||||
serverB?.close();
|
||||
});
|
||||
|
||||
function credentialsWithProxies() {
|
||||
return {
|
||||
apiKey: null,
|
||||
accessToken: null,
|
||||
connectionId: "noauth",
|
||||
providerSpecificData: {
|
||||
fingerprints: [ACCOUNT_A, ACCOUNT_B],
|
||||
accountProxies: [
|
||||
{ fingerprint: ACCOUNT_A, proxy: { type: "http", host: "127.0.0.1", port: portA } },
|
||||
{ fingerprint: ACCOUNT_B, proxy: { type: "http", host: "127.0.0.1", port: portB } },
|
||||
],
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("OpencodeExecutor per-account proxy + rotation (#4954)", () => {
|
||||
let originalFetch: typeof globalThis.fetch;
|
||||
let observed: Array<{ source: string; host: string | null; port: string | null }>;
|
||||
|
||||
beforeEach(() => {
|
||||
originalFetch = globalThis.fetch;
|
||||
observed = [];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
/** Record the proxy context resolved for each dispatch, then return `status`. */
|
||||
function installFetchStub(statuses: number[]) {
|
||||
let call = 0;
|
||||
globalThis.fetch = (async (input: any) => {
|
||||
const url = typeof input === "string" ? input : input?.url || String(input);
|
||||
const resolved = resolveProxyForRequest(url);
|
||||
let host: string | null = null;
|
||||
let port: string | null = null;
|
||||
try {
|
||||
if (resolved.proxyUrl) {
|
||||
const u = new URL(resolved.proxyUrl);
|
||||
host = u.hostname;
|
||||
port = u.port;
|
||||
}
|
||||
} catch {
|
||||
host = resolved.proxyUrl;
|
||||
}
|
||||
observed.push({ source: resolved.source, host, port });
|
||||
const status = statuses[Math.min(call, statuses.length - 1)];
|
||||
call++;
|
||||
return new Response(JSON.stringify({ ok: status === 200 }), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}) as typeof globalThis.fetch;
|
||||
}
|
||||
|
||||
it("dispatches through the selected account's proxy (not direct)", async () => {
|
||||
const exec = new OpencodeExecutor("opencode-zen");
|
||||
installFetchStub([200]);
|
||||
|
||||
const result = await exec.execute({
|
||||
model: "grok-code",
|
||||
body: { messages: [{ role: "user", content: "hi" }], stream: false },
|
||||
stream: false,
|
||||
signal: null,
|
||||
credentials: credentialsWithProxies(),
|
||||
log,
|
||||
});
|
||||
|
||||
assert.strictEqual((result as any).response.status, 200);
|
||||
assert.ok(observed.length >= 1, "at least one dispatch happened");
|
||||
const first = observed[0];
|
||||
assert.strictEqual(
|
||||
first.source,
|
||||
"context",
|
||||
`expected proxy-context egress, got source="${first.source}" (was always "direct" before #4954)`
|
||||
);
|
||||
assert.strictEqual(first.host, "127.0.0.1", "egress must use a configured proxy host");
|
||||
assert.ok(
|
||||
first.port === String(portA) || first.port === String(portB),
|
||||
`expected one of the configured proxy ports, got "${first.port}"`
|
||||
);
|
||||
});
|
||||
|
||||
it("rotates to the next account (and its proxy) on a 429", async () => {
|
||||
const exec = new OpencodeExecutor("opencode-zen");
|
||||
// first account → 429, second account → 200
|
||||
installFetchStub([429, 200]);
|
||||
|
||||
const result = await exec.execute({
|
||||
model: "grok-code",
|
||||
body: { messages: [{ role: "user", content: "hi" }], stream: false },
|
||||
stream: false,
|
||||
signal: null,
|
||||
credentials: credentialsWithProxies(),
|
||||
log,
|
||||
});
|
||||
|
||||
assert.strictEqual((result as any).response.status, 200, "final response should succeed");
|
||||
assert.ok(observed.length >= 2, "should have retried on a second account after 429");
|
||||
const ports = observed.map((p) => p.port);
|
||||
assert.ok(ports.includes(String(portA)), "first attempt should use account A's proxy");
|
||||
assert.ok(ports.includes(String(portB)), "rotated attempt should use account B's proxy");
|
||||
assert.notStrictEqual(
|
||||
observed[0].port,
|
||||
observed[1].port,
|
||||
"rotation must switch to a different account/proxy"
|
||||
);
|
||||
for (const p of observed) {
|
||||
assert.strictEqual(p.source, "context", "every dispatch must egress through a proxy context");
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user