Files
OmniRoute/tests/unit/combo-antigravity-missing-project-reset-8486.test.ts
Diego Rodrigues de Sa e Souza 2230fbbe93 fix(resilience): keep combo quality and auth reasons separate and redact connection labels in terminal errors (#10314) (#10501)
* fix(resilience): keep combo quality and auth reasons separate and redact connection labels in terminal errors (#10314)

* fix(resilience): sanitize identifiers in error text, add explicit terminal-status policy, fix classifier ordering (#10314)

Four gaps in the prior combo-error-aggregation fix:

- formatComboOutcomes() only redacted connection identifiers in the model
  label, never in the raw upstream error TEXT — a proxy echoing a
  connection/account id back in its error body leaked it into the
  client-facing terminal message. Redact both.

- The terminal HTTP status was still `lastStatus` — whichever target
  happened to fail last, independent of the other targets' reasons. Add
  resolveComboTerminalStatus(): preserve a 4xx only when every eligible
  target's failure is genuinely "the request is invalid" (model-class);
  a heterogeneous mix (e.g. a quality failure + a sibling's 401) now
  normalizes to a 5xx-class status reflecting an infra/provider problem,
  never a misleading client error borrowed from an unrelated target.

- classifyComboOutcome()'s ordering had `status === 408 || status >= 499`
  checked before `status >= 500`, making the provider branch permanently
  unreachable — every real 5xx (500/502/503/504) was silently mislabeled
  as "timeout". Fixed to an exact match (408/499) and gave 429 its own
  explicit `rate_limit` kind instead of falling into the generic "model"
  (request-invalid) bucket by accident.

- Added an integration-level regression driving the real handleComboChat
  wiring end-to-end (quality failure + sibling 401, and a success-after-
  quality-failure case), not just the pure aggregation helpers.

Updated three pre-existing tests whose assertions encoded the OLD
last-writer-wins contract this fix intentionally supersedes (#8486 Part B
antigravity retryAfter tests, two combo-routing-engine status/message
tests) to the new, more precise contract; verified the underlying #8486
concern (wrong target's retryAfter header) is still honored under the new
status policy.

---------

Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
2026-08-18 10:51:16 -03:00

128 lines
4.9 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-combo-8486-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "combo-8486-test-secret";
const { handleComboChat } = await import("../../open-sse/services/combo.ts");
const noop = () => {};
const log = { info: noop, warn: noop, debug: noop, error: noop };
function makeCombo(models: string[]) {
return {
name: "test-combo-8486",
strategy: "priority",
models: models.map((m) => ({ model: m })),
};
}
async function runScenario(models: string[]) {
const longRetryAfterMs = (21 * 3600 + 47 * 60 + 32) * 1000;
const longRetryAfterIso = new Date(Date.now() + longRetryAfterMs).toISOString();
const missingProjectBody = {
error: {
message:
"Missing Google projectId for Antigravity account. Auto-discovery via loadCodeAssist " +
"found no Cloud Code project. Please reconnect OAuth in Providers → Antigravity (and " +
"ensure the Google account has completed Gemini Code Assist onboarding).",
type: "oauth_missing_project_id",
code: "missing_project_id",
},
};
const modelsCalled: string[] = [];
const handleSingleModel = async (_body: unknown, modelStr: string) => {
modelsCalled.push(modelStr);
if (modelStr.includes("account-a")) {
return new Response(
JSON.stringify({
error: { message: "Your quota will reset after 21h47m32s." },
retryAfter: longRetryAfterIso,
}),
{ status: 429, headers: { "Content-Type": "application/json" } }
);
}
return new Response(JSON.stringify(missingProjectBody), {
status: 422,
headers: { "Content-Type": "application/json" },
});
};
const result = await handleComboChat({
body: { model: "test", messages: [{ role: "user", content: "hi" }] },
combo: makeCombo(models),
handleSingleModel,
log,
settings: {},
allCombos: [],
});
return { result, modelsCalled };
}
// #10314/#10501 superseded the original "one target's message wins, silently
// drops the sibling's reason" contract these two tests pinned: combo terminal
// aggregation now DELIBERATELY lists every distinct per-target reason (#10314)
// and normalizes a heterogeneous failure mix to a 5xx-class status instead of a
// bare `lastStatus` (#10501 — see comboErrorAggregation.ts::resolveComboTerminalStatus).
// The underlying #8486 concern — a config-class error getting the WRONG target's
// long retry-after window stitched onto it — is still the thing under test, just
// verified against the new contract: the response's `Retry-After` HEADER (the
// actual out-of-band decoration #8486 was about) must never carry the unrelated
// 21h47m window, in EITHER attempt order, regardless of which reasons appear in
// the (now intentionally multi-reason) message body.
test("#8486 Part B: heterogeneous rate_limit+config-class antigravity failure never attaches the unrelated 21h47m retryAfter as a response header", async () => {
const { result, modelsCalled } = await runScenario([
"antigravity/account-a-model",
"antigravity/account-b-model",
]);
assert.ok(
modelsCalled.some((m) => m.includes("account-a")) &&
modelsCalled.some((m) => m.includes("account-b")),
`expected both targets to be tried, got: ${JSON.stringify(modelsCalled)}`
);
// #10501: neither target's failure alone proves the CLIENT's request was
// invalid (one is a rate limit, the other a config/auth problem) — the
// heterogeneous mix must normalize to a 5xx infra/provider status.
assert.equal(result.status, 502);
assert.equal(
result.headers.get("Retry-After"),
null,
"the config-class 422 (no retryAfter of its own) must never end up decorated " +
"with account-a's unrelated 21h47m retry-after header"
);
// #10314: both distinct reasons are now surfaced (never silently dropped).
const text = await result.clone().text();
assert.match(text, /reset after 21h47m32s/i);
assert.match(text, /missing google projectid/i);
});
test("#8486 Part B (reverse order): same result independent of which target failed first — both reasons present, no bogus Retry-After header", async () => {
const { result, modelsCalled } = await runScenario([
"antigravity/account-b-model",
"antigravity/account-a-model",
]);
assert.ok(
modelsCalled.some((m) => m.includes("account-a")) &&
modelsCalled.some((m) => m.includes("account-b")),
`expected both targets to be tried, got: ${JSON.stringify(modelsCalled)}`
);
assert.equal(result.status, 502, "attempt order must not change the terminal status");
assert.equal(result.headers.get("Retry-After"), null);
const text = await result.clone().text();
assert.match(text, /reset after 21h47m32s/i);
assert.match(text, /missing google projectid/i);
});