mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
fix(antigravity): wrap Pro fallback chain in try/catch for timeout resilience (#7290)
* fix(antigravity): wrap executeOnce in try/catch for Pro fallback chain When a Pro-tier candidate times out or throws a network error, the exception now continues to the next candidate instead of aborting the entire chain. Includes diagnostic logging and unit tests. Signed-off-by: Minxi Hou <houminxi@gmail.com> * fix(antigravity): propagate abort signal in Pro fallback catch block Re-throw AbortError and signal.aborted immediately instead of retrying the next candidate. Prevents wasted upstream requests after client disconnect. Signed-off-by: Minxi Hou <houminxi@gmail.com> * fix(mitm): skip DNS modification when sudo unavailable (container) In containers (USER node, no sudo, not root) provisionDnsEntries() now detects the condition up-front and logs a clear message instead of attempting sudo and silently swallowing the error. Adds canElevate() to the injectable deps interface for testability, and supports SKIP_ANTIGRAVITY_DNS=true for explicit opt-out. * fix(antigravity): improve abort detection and fallback error handling Check Error.name === 'AbortError' for non-DOMException environments (polyfills, test harnesses). Capture first 400 from any candidate (not just i===0) so mixed paths surface the 400 instead of a generic error. Return firstResult when last candidate throws, consistent with the all-400 case. * test(mitm): add coverage for container-skip DNS provisioning * test(mitm): harden container-skip DNS test assertions The SKIP_ANTIGRAVITY_DNS=true and canElevate()=false tests used empty agentStates/customHosts, so they could not distinguish 'all steps skipped' from 'only the default step skipped'. Provide non-empty mocks and assert addHostsDns was NOT called. Also add a SKIP_ANTIGRAVITY_DNS=false boundary test confirming the strict === "true" comparison does not block normal provisioning, and verify sudoPassword passthrough in the canElevate=true happy-path test. * refactor(mitm): split provisionDnsEntries below complexity gate provisionDnsEntries() (complexity ~18, this PR's try/catch/log additions pushed it over check-complexity.mjs's threshold of 15) and execute()'s Pro-fallback loop (complexity 27, from wrapping executeOnce() in try/catch for timeout resilience) were both over the gate. Decomposed each into small named helpers, no behavior change: - provision.ts: split into provisionDefaultDns/provisionAgentDns/ provisionCustomHostsDns, each wrapping one best-effort DNS step. - antigravity.ts: extracted the fallback-chain catch/400-handling decisions (handleAntigravityFallbackChainError, isAntigravityAbortError, handleAntigravityFallback400) into a new antigravity/proFallbackChain.ts submodule (pure, no executor instance state), mirroring the existing antigravity/sseCollect.ts submodule pattern. Also fixes the antigravity.ts file-size cap (was pushed to 1854 lines > 1813 frozen ceiling by this PR's own try/catch addition; now 1771). execute/provisionDnsEntries no longer appear with ruleId complexity or max-lines-per-function in the check-complexity.mjs report. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * refactor(antigravity): drop redundant loop continue (cognitive-complexity gate) Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * refactor(antigravity): fold fallback outcome dispatch into switch (cognitive gate) Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Signed-off-by: Minxi Hou <houminxi@gmail.com> Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com> Co-authored-by: HouMinXi <19586012+HouMinXi@users.noreply.github.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
@@ -53,6 +53,10 @@ import {
|
||||
} from "./antigravity/sseCollect.ts";
|
||||
// processAntigravitySSEPayload re-exported for external importers (tests).
|
||||
export { processAntigravitySSEPayload } from "./antigravity/sseCollect.ts";
|
||||
import {
|
||||
handleAntigravityFallbackChainError,
|
||||
handleAntigravityFallback400,
|
||||
} from "./antigravity/proFallbackChain.ts";
|
||||
import {
|
||||
applyAntigravityClientProfileHeaders,
|
||||
removeHeaderCaseInsensitive,
|
||||
@@ -1070,7 +1074,28 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
let firstResult: Awaited<ReturnType<AntigravityExecutor["executeOnce"]>> | null = null;
|
||||
for (let i = 0; i < chain.length; i++) {
|
||||
const candidate = chain[i];
|
||||
const result = await this.executeOnce(input, candidate);
|
||||
let result: Awaited<ReturnType<AntigravityExecutor["executeOnce"]>>;
|
||||
try {
|
||||
result = await this.executeOnce(input, candidate);
|
||||
} catch (error) {
|
||||
const outcome = handleAntigravityFallbackChainError(
|
||||
input,
|
||||
error,
|
||||
candidate,
|
||||
i,
|
||||
chain,
|
||||
firstResult,
|
||||
resolvedUpstreamId
|
||||
);
|
||||
switch (outcome.action) {
|
||||
case "throw":
|
||||
throw outcome.error;
|
||||
case "return":
|
||||
return outcome.result;
|
||||
default:
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Success (or any non-400) on a candidate → return immediately.
|
||||
if (result.response.status !== HTTP_STATUS.BAD_REQUEST) {
|
||||
@@ -1078,23 +1103,18 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
}
|
||||
|
||||
// Remember the FIRST 400 so the exhausted-chain case surfaces the original error.
|
||||
if (i === 0) firstResult = result;
|
||||
if (!firstResult) firstResult = result;
|
||||
|
||||
const isLast = i === chain.length - 1;
|
||||
if (!isLast) {
|
||||
input.log?.debug?.(
|
||||
"AG_PRO_FALLBACK",
|
||||
`400 on "${candidate}" — retrying with next Pro candidate "${chain[i + 1]}"`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Chain exhausted: surface the FIRST candidate's sanitized 400.
|
||||
input.log?.warn?.(
|
||||
"AG_PRO_FALLBACK",
|
||||
`Pro fallback chain exhausted (all ${chain.length} candidates 400'd) for "${resolvedUpstreamId}"`
|
||||
const outcome400 = handleAntigravityFallback400(
|
||||
input,
|
||||
result,
|
||||
firstResult,
|
||||
candidate,
|
||||
i,
|
||||
chain,
|
||||
resolvedUpstreamId
|
||||
);
|
||||
return firstResult ?? result;
|
||||
if (outcome400.action === "return") return outcome400.result;
|
||||
}
|
||||
|
||||
// Unreachable (loop always returns), but keeps the type checker happy.
|
||||
|
||||
104
open-sse/executors/antigravity/proFallbackChain.ts
Normal file
104
open-sse/executors/antigravity/proFallbackChain.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
// Pure Pro-family fallback-chain decision helpers for the Antigravity executor (#7290):
|
||||
// decide what execute()'s per-candidate loop does after executeOnce() throws or
|
||||
// returns a 400, without depending on executor instance state (no `this`).
|
||||
// Extracted from antigravity.ts (file-size cap) -- mirrors the existing
|
||||
// antigravity/sseCollect.ts submodule pattern.
|
||||
import type { ExecuteInput } from "../base.ts";
|
||||
|
||||
/** Shape of one execute()/executeOnce() result (kept local to avoid importing the class). */
|
||||
export type AntigravityExecuteResult = {
|
||||
response: Response;
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
transformedBody: unknown;
|
||||
};
|
||||
|
||||
/** True for an aborted request (caller disconnect) — never retried across candidates. */
|
||||
export function isAntigravityAbortError(input: ExecuteInput, error: unknown): boolean {
|
||||
return Boolean(
|
||||
input.signal?.aborted ||
|
||||
(error instanceof DOMException && error.name === "AbortError") ||
|
||||
(error instanceof Error && error.name === "AbortError")
|
||||
);
|
||||
}
|
||||
|
||||
export type AntigravityFallbackChainErrorOutcome =
|
||||
| { action: "throw"; error: unknown }
|
||||
| { action: "return"; result: AntigravityExecuteResult }
|
||||
| { action: "continue" };
|
||||
|
||||
/**
|
||||
* Decide what execute()'s Pro-fallback loop does after executeOnce() THROWS for one
|
||||
* candidate: propagate an abort immediately, retry the next candidate, surface the
|
||||
* first 400 if the chain is exhausted, or throw a chain-exhausted error.
|
||||
*/
|
||||
export function handleAntigravityFallbackChainError(
|
||||
input: ExecuteInput,
|
||||
error: unknown,
|
||||
candidate: string,
|
||||
i: number,
|
||||
chain: readonly string[],
|
||||
firstResult: AntigravityExecuteResult | null,
|
||||
resolvedUpstreamId: string
|
||||
): AntigravityFallbackChainErrorOutcome {
|
||||
// Abort signal (user disconnect) — propagate immediately, do not retry.
|
||||
if (isAntigravityAbortError(input, error)) {
|
||||
return { action: "throw", error };
|
||||
}
|
||||
if (i < chain.length - 1) {
|
||||
input.log?.debug?.(
|
||||
"AG_PRO_FALLBACK",
|
||||
`Exception on "${candidate}" (${error instanceof Error ? error.message : String(error)}) -- retrying with next Pro candidate "${chain[i + 1]}"`
|
||||
);
|
||||
return { action: "continue" };
|
||||
}
|
||||
// Last candidate also threw -- return original 400 if available, otherwise throw.
|
||||
if (firstResult) {
|
||||
input.log?.warn?.(
|
||||
"AG_PRO_FALLBACK",
|
||||
`Pro fallback chain exhausted (last candidate threw, but first candidate returned 400) for "${resolvedUpstreamId}". Returning original 400.`
|
||||
);
|
||||
return { action: "return", result: firstResult };
|
||||
}
|
||||
return {
|
||||
action: "throw",
|
||||
error: new Error(
|
||||
`Pro fallback chain exhausted (all ${chain.length} candidates failed). Last error: ${error instanceof Error ? error.message : String(error)}`
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export type AntigravityFallback400Outcome =
|
||||
| { action: "return"; result: AntigravityExecuteResult }
|
||||
| { action: "continue" };
|
||||
|
||||
/**
|
||||
* Decide what execute()'s Pro-fallback loop does after one candidate returns a 400:
|
||||
* retry the next candidate, or (chain exhausted) surface the first candidate's
|
||||
* sanitized 400.
|
||||
*/
|
||||
export function handleAntigravityFallback400(
|
||||
input: ExecuteInput,
|
||||
result: AntigravityExecuteResult,
|
||||
firstResult: AntigravityExecuteResult | null,
|
||||
candidate: string,
|
||||
i: number,
|
||||
chain: readonly string[],
|
||||
resolvedUpstreamId: string
|
||||
): AntigravityFallback400Outcome {
|
||||
const isLast = i === chain.length - 1;
|
||||
if (!isLast) {
|
||||
input.log?.debug?.(
|
||||
"AG_PRO_FALLBACK",
|
||||
`400 on "${candidate}" — retrying with next Pro candidate "${chain[i + 1]}"`
|
||||
);
|
||||
return { action: "continue" };
|
||||
}
|
||||
|
||||
// Chain exhausted: surface the FIRST candidate's sanitized 400.
|
||||
input.log?.warn?.(
|
||||
"AG_PRO_FALLBACK",
|
||||
`Pro fallback chain exhausted (all ${chain.length} candidates 400'd) for "${resolvedUpstreamId}"`
|
||||
);
|
||||
return { action: "return", result: firstResult ?? result };
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
* is guarded and unit-testable without spawning the MITM server (#6127 / #6198).
|
||||
*/
|
||||
|
||||
import { addDNSEntry, addDNSEntries } from "./dnsConfig.ts";
|
||||
import { addDNSEntry, addDNSEntries, isSudoAvailable } from "./dnsConfig.ts";
|
||||
import { isRoot } from "../systemCommands.ts";
|
||||
import { ALL_TARGETS } from "../targets/index.ts";
|
||||
import { getAllAgentBridgeStates } from "@/lib/db/agentBridgeState.ts";
|
||||
import { listCustomHosts } from "@/lib/db/inspectorCustomHosts.ts";
|
||||
@@ -23,9 +24,73 @@ export interface DnsProvisionDeps {
|
||||
addHostsDns?: (hosts: string[], sudoPassword: string) => Promise<void>;
|
||||
getAgentStates?: () => ReturnType<typeof getAllAgentBridgeStates>;
|
||||
listEnabledCustomHosts?: () => ReturnType<typeof listCustomHosts>;
|
||||
/** Return true if privileged host-file writes are possible (sudo or root). */
|
||||
canElevate?: () => boolean;
|
||||
logger?: DnsProvisionLogger;
|
||||
}
|
||||
|
||||
/** Fully-resolved dependency set used by the per-step provisioning helpers below. */
|
||||
type ResolvedDnsProvisionDeps = {
|
||||
addDefaultDns: (sudoPassword: string) => Promise<void>;
|
||||
addHostsDns: (hosts: string[], sudoPassword: string) => Promise<void>;
|
||||
getAgentStates: () => ReturnType<typeof getAllAgentBridgeStates>;
|
||||
listEnabledCustomHosts: () => ReturnType<typeof listCustomHosts>;
|
||||
logger: DnsProvisionLogger;
|
||||
};
|
||||
|
||||
/** Antigravity default hosts — best-effort, never throws. */
|
||||
async function provisionDefaultDns(
|
||||
sudoPassword: string,
|
||||
deps: ResolvedDnsProvisionDeps
|
||||
): Promise<void> {
|
||||
try {
|
||||
await deps.addDefaultDns(sudoPassword);
|
||||
} catch (err) {
|
||||
deps.logger.error({ err }, "Failed to add default DNS entries (continuing)");
|
||||
}
|
||||
}
|
||||
|
||||
/** Hosts for agents with `dns_enabled=true` in the DB — best-effort, never throws. */
|
||||
async function provisionAgentDns(
|
||||
sudoPassword: string,
|
||||
deps: ResolvedDnsProvisionDeps
|
||||
): Promise<void> {
|
||||
try {
|
||||
const agentStates = deps.getAgentStates();
|
||||
const agentHostsToAdd: string[] = [];
|
||||
for (const state of agentStates) {
|
||||
if (!state.dns_enabled) continue;
|
||||
const target = ALL_TARGETS.find((t) => t.id === state.agent_id);
|
||||
if (target) {
|
||||
agentHostsToAdd.push(...target.hosts);
|
||||
}
|
||||
}
|
||||
if (agentHostsToAdd.length > 0) {
|
||||
deps.logger.info({ count: agentHostsToAdd.length }, "Adding DNS for agent host(s)...");
|
||||
await deps.addHostsDns(agentHostsToAdd, sudoPassword);
|
||||
}
|
||||
} catch (err) {
|
||||
deps.logger.error({ err }, "Failed to add agent DNS entries (continuing)");
|
||||
}
|
||||
}
|
||||
|
||||
/** Enabled custom hosts — best-effort, never throws. */
|
||||
async function provisionCustomHostsDns(
|
||||
sudoPassword: string,
|
||||
deps: ResolvedDnsProvisionDeps
|
||||
): Promise<void> {
|
||||
try {
|
||||
const customHosts = deps.listEnabledCustomHosts();
|
||||
const customHostNames = customHosts.map((h) => h.host);
|
||||
if (customHostNames.length > 0) {
|
||||
deps.logger.info({ count: customHostNames.length }, "Adding DNS for custom host(s)...");
|
||||
await deps.addHostsDns(customHostNames, sudoPassword);
|
||||
}
|
||||
} catch (err) {
|
||||
deps.logger.error({ err }, "Failed to add custom host DNS entries (continuing)");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provision every AgentBridge DNS entry (Antigravity defaults + agents with
|
||||
* `dns_enabled=true` + enabled custom hosts). **Every step is best-effort**: a failure
|
||||
@@ -45,48 +110,35 @@ export async function provisionDnsEntries(
|
||||
sudoPassword: string,
|
||||
deps: DnsProvisionDeps = {}
|
||||
): Promise<void> {
|
||||
const addDefaultDns = deps.addDefaultDns ?? addDNSEntry;
|
||||
const addHostsDns = deps.addHostsDns ?? addDNSEntries;
|
||||
const getAgentStates = deps.getAgentStates ?? getAllAgentBridgeStates;
|
||||
const listEnabledCustomHosts =
|
||||
deps.listEnabledCustomHosts ?? (() => listCustomHosts({ enabledOnly: true }));
|
||||
const canElevate = deps.canElevate ?? (() => isSudoAvailable() || isRoot());
|
||||
const logger = deps.logger ?? defaultLog;
|
||||
|
||||
// Antigravity default hosts.
|
||||
try {
|
||||
await addDefaultDns(sudoPassword);
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Failed to add default DNS entries (continuing)");
|
||||
// Explicit opt-out: skip all DNS modification when the env var is set.
|
||||
if (process.env.SKIP_ANTIGRAVITY_DNS === "true") {
|
||||
logger.info("Skipping DNS entries - SKIP_ANTIGRAVITY_DNS=true");
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect hosts from agents that have dns_enabled=true in the DB.
|
||||
try {
|
||||
const agentStates = getAgentStates();
|
||||
const agentHostsToAdd: string[] = [];
|
||||
for (const state of agentStates) {
|
||||
if (!state.dns_enabled) continue;
|
||||
const target = ALL_TARGETS.find((t) => t.id === state.agent_id);
|
||||
if (target) {
|
||||
agentHostsToAdd.push(...target.hosts);
|
||||
}
|
||||
}
|
||||
if (agentHostsToAdd.length > 0) {
|
||||
logger.info({ count: agentHostsToAdd.length }, "Adding DNS for agent host(s)...");
|
||||
await addHostsDns(agentHostsToAdd, sudoPassword);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Failed to add agent DNS entries (continuing)");
|
||||
// In containers (USER node, no sudo installed, not root) we cannot write
|
||||
// to /etc/hosts. Rather than attempting sudo and swallowing the error,
|
||||
// detect the condition up-front and bail out with a clear message.
|
||||
if (!canElevate()) {
|
||||
logger.info(
|
||||
"Skipping DNS entries - sudo not available and not running as root (likely a container)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect enabled custom hosts.
|
||||
try {
|
||||
const customHosts = listEnabledCustomHosts();
|
||||
const customHostNames = customHosts.map((h) => h.host);
|
||||
if (customHostNames.length > 0) {
|
||||
logger.info({ count: customHostNames.length }, "Adding DNS for custom host(s)...");
|
||||
await addHostsDns(customHostNames, sudoPassword);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Failed to add custom host DNS entries (continuing)");
|
||||
}
|
||||
const resolvedDeps: ResolvedDnsProvisionDeps = {
|
||||
addDefaultDns: deps.addDefaultDns ?? addDNSEntry,
|
||||
addHostsDns: deps.addHostsDns ?? addDNSEntries,
|
||||
getAgentStates: deps.getAgentStates ?? getAllAgentBridgeStates,
|
||||
listEnabledCustomHosts:
|
||||
deps.listEnabledCustomHosts ?? (() => listCustomHosts({ enabledOnly: true })),
|
||||
logger,
|
||||
};
|
||||
|
||||
await provisionDefaultDns(sudoPassword, resolvedDeps);
|
||||
await provisionAgentDns(sudoPassword, resolvedDeps);
|
||||
await provisionCustomHostsDns(sudoPassword, resolvedDeps);
|
||||
}
|
||||
|
||||
@@ -190,6 +190,136 @@ test("(#3786) happy path: first id 200 makes exactly ONE upstream call (zero ext
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Behavioral: executor catches exceptions and continues the fallback chain
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("(#3786) exception on first candidate (timeout) falls through to second candidate returning 200", async () => {
|
||||
const executor = new AntigravityExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
seedAntigravityVersionCache("2026.04.17-test");
|
||||
const modelsTried: string[] = [];
|
||||
|
||||
globalThis.fetch = (async (_url: string, init?: RequestInit) => {
|
||||
const m = envelopeModel(init);
|
||||
modelsTried.push(m);
|
||||
if (m === "gemini-3.1-pro-high") throw new Error("upstream timeout after 30000ms");
|
||||
return makeSuccessSSE();
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const result = await executor.execute({
|
||||
model: "antigravity/gemini-3.1-pro-high",
|
||||
body: { request: { contents: [] } },
|
||||
stream: false,
|
||||
credentials: { accessToken: "token", projectId: "project-1" },
|
||||
log: { debug() {}, warn() {}, info() {} },
|
||||
});
|
||||
const payload = (await result.response.json()) as ChatCompletionPayload;
|
||||
|
||||
assert.equal(result.response.status, 200, "second candidate should succeed after first threw");
|
||||
assert.equal(payload.choices[0].message.content, "OK");
|
||||
// First candidate retried internally (URL-level retries on throw), then second succeeded.
|
||||
assert.equal(modelsTried[0], "gemini-3.1-pro-high", "first call targets first candidate");
|
||||
assert.ok(modelsTried.includes("gemini-pro-agent"), "must eventually try second candidate");
|
||||
assert.ok(
|
||||
modelsTried.lastIndexOf("gemini-3.1-pro-high") < modelsTried.indexOf("gemini-pro-agent"),
|
||||
"first candidate exhausted before second tried"
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("(#3786) all candidates throw exceptions -- error includes 'chain exhausted'", async () => {
|
||||
const executor = new AntigravityExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
seedAntigravityVersionCache("2026.04.17-test");
|
||||
const modelsTried: string[] = [];
|
||||
|
||||
globalThis.fetch = (async (_url: string, init?: RequestInit) => {
|
||||
const m = envelopeModel(init);
|
||||
modelsTried.push(m);
|
||||
throw new Error(`timeout on ${m}`);
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
executor.execute({
|
||||
model: "antigravity/gemini-3.1-pro-high",
|
||||
body: { request: { contents: [] } },
|
||||
stream: false,
|
||||
credentials: { accessToken: "token", projectId: "project-1" },
|
||||
log: { debug() {}, warn() {}, info() {} },
|
||||
}),
|
||||
(err: Error) => {
|
||||
assert.ok(
|
||||
err.message.includes("chain exhausted"),
|
||||
`error must mention chain exhausted, got: ${err.message}`
|
||||
);
|
||||
assert.ok(
|
||||
err.message.includes("timeout on"),
|
||||
`error must include last error message, got: ${err.message}`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
// All 3 candidates tried (each retries internally on throw).
|
||||
assert.ok(modelsTried.includes("gemini-3.1-pro-high"), "tried first candidate");
|
||||
assert.ok(modelsTried.includes("gemini-pro-agent"), "tried second candidate");
|
||||
assert.ok(modelsTried.includes("gemini-3-pro-high"), "tried third candidate");
|
||||
// Ordering: all first-candidate attempts before second, etc.
|
||||
const lastFirst = modelsTried.lastIndexOf("gemini-3.1-pro-high");
|
||||
const firstSecond = modelsTried.indexOf("gemini-pro-agent");
|
||||
const lastSecond = modelsTried.lastIndexOf("gemini-pro-agent");
|
||||
const firstThird = modelsTried.indexOf("gemini-3-pro-high");
|
||||
assert.ok(lastFirst < firstSecond, "first candidate exhausted before second tried");
|
||||
assert.ok(lastSecond < firstThird, "second candidate exhausted before third tried");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("(#3786) mixed path: 400 on first, exception on second, 200 on third", async () => {
|
||||
const executor = new AntigravityExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
seedAntigravityVersionCache("2026.04.17-test");
|
||||
const modelsTried: string[] = [];
|
||||
|
||||
globalThis.fetch = (async (_url: string, init?: RequestInit) => {
|
||||
const m = envelopeModel(init);
|
||||
modelsTried.push(m);
|
||||
if (m === "gemini-3.1-pro-high") return make400(m);
|
||||
if (m === "gemini-pro-agent") throw new Error("connection reset");
|
||||
return makeSuccessSSE();
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const result = await executor.execute({
|
||||
model: "antigravity/gemini-3.1-pro-high",
|
||||
body: { request: { contents: [] } },
|
||||
stream: false,
|
||||
credentials: { accessToken: "token", projectId: "project-1" },
|
||||
log: { debug() {}, warn() {}, info() {} },
|
||||
});
|
||||
const payload = (await result.response.json()) as ChatCompletionPayload;
|
||||
|
||||
assert.equal(result.response.status, 200, "third candidate should succeed");
|
||||
assert.equal(payload.choices[0].message.content, "OK");
|
||||
// First candidate (400, no retry) -> second candidate (throws, retried internally) -> third (200).
|
||||
assert.equal(modelsTried[0], "gemini-3.1-pro-high", "first call targets first candidate");
|
||||
assert.ok(modelsTried.includes("gemini-pro-agent"), "second candidate attempted");
|
||||
assert.ok(modelsTried.includes("gemini-3-pro-high"), "third candidate reached");
|
||||
assert.ok(
|
||||
modelsTried.lastIndexOf("gemini-pro-agent") < modelsTried.indexOf("gemini-3-pro-high"),
|
||||
"second candidate exhausted before third tried"
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("(#3786) a non-pro model that 400s does NOT trigger the fallback chain", async () => {
|
||||
const executor = new AntigravityExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
@@ -217,3 +347,76 @@ test("(#3786) a non-pro model that 400s does NOT trigger the fallback chain", as
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("(#3786) mixed: first 400 + last throws returns firstResult (original 400) instead of throwing", async () => {
|
||||
const executor = new AntigravityExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
seedAntigravityVersionCache("2026.04.17-test");
|
||||
const modelsTried: string[] = [];
|
||||
|
||||
globalThis.fetch = (async (_url: string, init?: RequestInit) => {
|
||||
const m = envelopeModel(init);
|
||||
modelsTried.push(m);
|
||||
if (m === "gemini-3.1-pro-high") return make400(m);
|
||||
// Second and third candidates throw
|
||||
throw new Error(`connection reset on ${m}`);
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const result = await executor.execute({
|
||||
model: "antigravity/gemini-3.1-pro-high",
|
||||
body: { request: { contents: [] } },
|
||||
stream: false,
|
||||
credentials: { accessToken: "token", projectId: "project-1" },
|
||||
log: { debug() {}, warn() {}, info() {} },
|
||||
});
|
||||
|
||||
// Returns the original 400 from firstResult, not a thrown error.
|
||||
assert.equal(result.response.status, 400, "should return first candidate's 400");
|
||||
const payload = (await result.response.json()) as ErrorPayload;
|
||||
assert.ok(payload.error, "must carry an error object");
|
||||
assert.equal(typeof payload.error.message, "string");
|
||||
assert.ok(!payload.error.message.includes("at /"), "no raw stack trace (hard rule #12)");
|
||||
// All candidates tried (executeOnce retries internally, so duplicates are expected).
|
||||
assert.ok(modelsTried.includes("gemini-3.1-pro-high"), "first candidate tried");
|
||||
assert.ok(modelsTried.includes("gemini-pro-agent"), "second candidate tried");
|
||||
assert.ok(modelsTried.includes("gemini-3-pro-high"), "third candidate tried");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("(#3786) AbortError from standard Error (not DOMException) propagates immediately", async () => {
|
||||
const executor = new AntigravityExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
seedAntigravityVersionCache("2026.04.17-test");
|
||||
// Signal is NOT aborted -- this exercises the new Error.name === "AbortError" path.
|
||||
const controller = new AbortController();
|
||||
|
||||
globalThis.fetch = (async (_url: string, _init?: RequestInit) => {
|
||||
// Simulate a polyfill/test env that throws standard Error with name AbortError
|
||||
const err = new Error("The operation was aborted");
|
||||
err.name = "AbortError";
|
||||
throw err;
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
executor.execute({
|
||||
model: "antigravity/gemini-3.1-pro-high",
|
||||
body: { request: { contents: [] } },
|
||||
stream: false,
|
||||
credentials: { accessToken: "token", projectId: "project-1" },
|
||||
signal: controller.signal,
|
||||
log: { debug() {}, warn() {}, info() {} },
|
||||
}),
|
||||
(err: Error) => {
|
||||
assert.equal(err.name, "AbortError", "must propagate as AbortError");
|
||||
return true;
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -27,8 +27,7 @@ function makeSpyLogger() {
|
||||
return {
|
||||
logger: {
|
||||
error: (payload: unknown, msg: string) => errorCalls.push({ payload, msg }),
|
||||
info: (payload: unknown, msg?: string) =>
|
||||
infoCalls.push({ payload, msg: msg ?? "" }),
|
||||
info: (payload: unknown, msg?: string) => infoCalls.push({ payload, msg: msg ?? "" }),
|
||||
},
|
||||
errorCalls,
|
||||
infoCalls,
|
||||
@@ -83,8 +82,7 @@ test("provisionDnsEntries: a failing agent/custom step does not stop the others
|
||||
// Custom-hosts call must still happen even after default + agent errors.
|
||||
if (hosts.includes("custom.example.com")) customCalled = true;
|
||||
},
|
||||
getAgentStates: () =>
|
||||
[{ dns_enabled: true, agent_id: "__nonexistent_agent__" }] as never,
|
||||
getAgentStates: () => [{ dns_enabled: true, agent_id: "__nonexistent_agent__" }] as never,
|
||||
listEnabledCustomHosts: () => [{ host: "custom.example.com" }] as never,
|
||||
logger: spy.logger,
|
||||
});
|
||||
@@ -105,3 +103,124 @@ test("provisionDnsEntries: happy path calls the default step and does not log er
|
||||
assert.ok(defaultCalled, "default DNS step must be invoked");
|
||||
assert.equal(spy.errorCalls.length, 0, "no error logs on the happy path");
|
||||
});
|
||||
|
||||
test("provisionDnsEntries: SKIP_ANTIGRAVITY_DNS=true skips ALL DNS steps (default + agents + custom)", async () => {
|
||||
const prev = process.env.SKIP_ANTIGRAVITY_DNS;
|
||||
process.env.SKIP_ANTIGRAVITY_DNS = "true";
|
||||
try {
|
||||
const spy = makeSpyLogger();
|
||||
let defaultCalled = false;
|
||||
let hostsCalled = false;
|
||||
await provisionDnsEntries("pw", {
|
||||
addDefaultDns: async () => {
|
||||
defaultCalled = true;
|
||||
},
|
||||
addHostsDns: async () => {
|
||||
hostsCalled = true;
|
||||
},
|
||||
getAgentStates: () => [{ dns_enabled: true, agent_id: "antigravity" }] as never,
|
||||
listEnabledCustomHosts: () => [{ host: "custom.example.com" }] as never,
|
||||
logger: spy.logger,
|
||||
});
|
||||
assert.ok(!defaultCalled, "default DNS step must NOT be called when SKIP_ANTIGRAVITY_DNS=true");
|
||||
assert.ok(!hostsCalled, "addHostsDns must NOT be called when SKIP_ANTIGRAVITY_DNS=true");
|
||||
assert.equal(spy.errorCalls.length, 0, "no errors expected");
|
||||
assert.ok(
|
||||
spy.infoCalls.some(
|
||||
(c) => typeof c.payload === "string" && c.payload.includes("SKIP_ANTIGRAVITY_DNS")
|
||||
),
|
||||
"info log must mention SKIP_ANTIGRAVITY_DNS"
|
||||
);
|
||||
} finally {
|
||||
// Restore the original env var value.
|
||||
if (prev === undefined) {
|
||||
delete process.env.SKIP_ANTIGRAVITY_DNS;
|
||||
} else {
|
||||
process.env.SKIP_ANTIGRAVITY_DNS = prev;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("provisionDnsEntries: SKIP_ANTIGRAVITY_DNS=false does NOT skip DNS steps", async () => {
|
||||
const prev = process.env.SKIP_ANTIGRAVITY_DNS;
|
||||
process.env.SKIP_ANTIGRAVITY_DNS = "false";
|
||||
try {
|
||||
const spy = makeSpyLogger();
|
||||
let defaultCalled = false;
|
||||
await provisionDnsEntries("pw", {
|
||||
addDefaultDns: async () => {
|
||||
defaultCalled = true;
|
||||
},
|
||||
getAgentStates: () => [],
|
||||
listEnabledCustomHosts: () => [],
|
||||
logger: spy.logger,
|
||||
});
|
||||
assert.ok(defaultCalled, "default DNS step must be called when SKIP_ANTIGRAVITY_DNS=false");
|
||||
assert.equal(spy.errorCalls.length, 0, "no errors expected");
|
||||
} finally {
|
||||
if (prev === undefined) {
|
||||
delete process.env.SKIP_ANTIGRAVITY_DNS;
|
||||
} else {
|
||||
process.env.SKIP_ANTIGRAVITY_DNS = prev;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("provisionDnsEntries: canElevate()=false skips ALL DNS steps (default + agents + custom)", async () => {
|
||||
const spy = makeSpyLogger();
|
||||
let defaultCalled = false;
|
||||
let hostsCalled = false;
|
||||
await provisionDnsEntries("pw", {
|
||||
addDefaultDns: async () => {
|
||||
defaultCalled = true;
|
||||
},
|
||||
addHostsDns: async () => {
|
||||
hostsCalled = true;
|
||||
},
|
||||
canElevate: () => false,
|
||||
getAgentStates: () => [{ dns_enabled: true, agent_id: "antigravity" }] as never,
|
||||
listEnabledCustomHosts: () => [{ host: "custom.example.com" }] as never,
|
||||
logger: spy.logger,
|
||||
});
|
||||
assert.ok(!defaultCalled, "default DNS step must NOT be called when canElevate() is false");
|
||||
assert.ok(!hostsCalled, "addHostsDns must NOT be called when canElevate() is false");
|
||||
assert.equal(spy.errorCalls.length, 0, "no errors expected");
|
||||
assert.ok(
|
||||
spy.infoCalls.some(
|
||||
(c) => typeof c.payload === "string" && c.payload.includes("sudo not available")
|
||||
),
|
||||
"info log must mention sudo not available"
|
||||
);
|
||||
});
|
||||
|
||||
test("provisionDnsEntries: canElevate() returning true proceeds with DNS provisioning", async () => {
|
||||
const spy = makeSpyLogger();
|
||||
let defaultCalled = false;
|
||||
let agentCalled = false;
|
||||
let customCalled = false;
|
||||
const capturedPasswords: string[] = [];
|
||||
await provisionDnsEntries("pw", {
|
||||
addDefaultDns: async () => {
|
||||
defaultCalled = true;
|
||||
},
|
||||
addHostsDns: async (hosts: string[], sudoPassword: string) => {
|
||||
capturedPasswords.push(sudoPassword);
|
||||
if (hosts.some((h) => h.includes("googleapis.com"))) agentCalled = true;
|
||||
if (hosts.includes("custom.example.com")) customCalled = true;
|
||||
},
|
||||
canElevate: () => true,
|
||||
getAgentStates: () => [{ dns_enabled: true, agent_id: "antigravity" }] as never,
|
||||
listEnabledCustomHosts: () => [{ host: "custom.example.com" }] as never,
|
||||
logger: spy.logger,
|
||||
});
|
||||
assert.ok(defaultCalled, "default DNS step must be called when canElevate() is true");
|
||||
assert.ok(agentCalled, "agent DNS step must be called when canElevate() is true");
|
||||
assert.ok(customCalled, "custom DNS step must be called when canElevate() is true");
|
||||
assert.equal(spy.errorCalls.length, 0, "no errors expected on happy path");
|
||||
// Verify sudoPassword is passed through to addHostsDns.
|
||||
assert.equal(capturedPasswords.length, 2, "addHostsDns called twice (agents + custom)");
|
||||
assert.ok(
|
||||
capturedPasswords.every((p) => p === "pw"),
|
||||
"sudoPassword must be forwarded to addHostsDns"
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user