700 lines
24 KiB
TypeScript
700 lines
24 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import fsp from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import net from "node:net";
|
|
import { spawn } from "node:child_process";
|
|
import { fileURLToPath } from "node:url";
|
|
import { MockUpstreamServer, buildCompletion, buildError } from "./helpers/mockUpstreamServer.ts";
|
|
|
|
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-system-failover-"));
|
|
const DASHBOARD_PORT = await getFreePort();
|
|
const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url));
|
|
|
|
process.env.DATA_DIR = TEST_DATA_DIR;
|
|
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
|
|
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "system-failover-secret-123456";
|
|
process.env.REQUIRE_API_KEY = "false";
|
|
|
|
const core = await import("../../src/lib/db/core.ts");
|
|
const providersDb = await import("../../src/lib/db/providers.ts");
|
|
const combosDb = await import("../../src/lib/db/combos.ts");
|
|
const settingsDb = await import("../../src/lib/db/settings.ts");
|
|
const accountFallback = await import("../../open-sse/services/accountFallback.ts");
|
|
|
|
function resetConnectionCooldowns() {
|
|
accountFallback.clearAllModelLockouts();
|
|
const db = core.getDbInstance() as any;
|
|
db.prepare(
|
|
`UPDATE provider_connections
|
|
SET rate_limited_until = NULL,
|
|
test_status = 'active',
|
|
backoff_level = 0,
|
|
last_error = NULL,
|
|
last_error_type = NULL,
|
|
last_error_source = NULL,
|
|
error_code = NULL,
|
|
last_error_at = NULL
|
|
WHERE rate_limited_until IS NOT NULL
|
|
OR test_status != 'active'`
|
|
).run();
|
|
db.pragma("wal_checkpoint(TRUNCATE)");
|
|
}
|
|
|
|
function getFreePort() {
|
|
return new Promise<number>((resolve, reject) => {
|
|
const server = net.createServer();
|
|
server.once("error", reject);
|
|
server.listen(0, "127.0.0.1", () => {
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
server.close();
|
|
reject(new Error("Failed to allocate a free port"));
|
|
return;
|
|
}
|
|
const { port } = address;
|
|
server.close((closeError) => {
|
|
if (closeError) reject(closeError);
|
|
else resolve(port);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
function sleep(ms: number) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
async function seedProvider(label: string, apiKey: string, baseUrl: string) {
|
|
const providerId = `openai-compatible-sys-${label}`;
|
|
await providersDb.createProviderNode({
|
|
id: providerId,
|
|
type: "openai-compatible",
|
|
name: `System ${label}`,
|
|
prefix: label,
|
|
apiType: "chat",
|
|
baseUrl,
|
|
});
|
|
await providersDb.createProviderConnection({
|
|
provider: providerId,
|
|
authType: "apikey",
|
|
name: `conn-${label}`,
|
|
apiKey,
|
|
isActive: true,
|
|
testStatus: "active",
|
|
providerSpecificData: { baseUrl, apiType: "chat" },
|
|
});
|
|
return { providerId, model: `${label}/test-model`, apiKey };
|
|
}
|
|
|
|
function createServerProcess(dataDir: string, port: number) {
|
|
const stdoutLines: string[] = [];
|
|
const stderrLines: string[] = [];
|
|
let exitInfo: { code: number | null; signal: NodeJS.Signals | null } | null = null;
|
|
const child = spawn(process.execPath, ["scripts/dev/run-next-playwright.mjs", "dev"], {
|
|
cwd: REPO_ROOT,
|
|
env: {
|
|
...process.env,
|
|
DATA_DIR: dataDir,
|
|
PORT: String(port),
|
|
DASHBOARD_PORT: String(port),
|
|
API_PORT: String(port),
|
|
HOST: "127.0.0.1",
|
|
REQUIRE_API_KEY: "false",
|
|
API_KEY_SECRET: process.env.API_KEY_SECRET || "system-failover-secret-123456",
|
|
DISABLE_SQLITE_AUTO_BACKUP: "true",
|
|
INITIAL_PASSWORD: "",
|
|
NEXT_TELEMETRY_DISABLED: "1",
|
|
OMNIROUTE_DISABLE_BACKGROUND_SERVICES: "true",
|
|
OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK: "true",
|
|
OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK: "true",
|
|
OMNIROUTE_HIDE_HEALTHCHECK_LOGS: "true",
|
|
OMNIROUTE_E2E_BOOTSTRAP_MODE: "open",
|
|
},
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
|
|
child.once("exit", (code, signal) => {
|
|
exitInfo = { code, signal };
|
|
});
|
|
child.stdout.on("data", (chunk) => {
|
|
const lines = String(chunk).split(/\r?\n/).filter(Boolean);
|
|
stdoutLines.push(...lines);
|
|
if (stdoutLines.length > 200) stdoutLines.splice(0, stdoutLines.length - 200);
|
|
});
|
|
child.stderr.on("data", (chunk) => {
|
|
const lines = String(chunk).split(/\r?\n/).filter(Boolean);
|
|
stderrLines.push(...lines);
|
|
if (stderrLines.length > 200) stderrLines.splice(0, stderrLines.length - 200);
|
|
});
|
|
|
|
return {
|
|
child,
|
|
stdoutLines,
|
|
stderrLines,
|
|
baseUrl: `http://127.0.0.1:${port}`,
|
|
get exitInfo() {
|
|
return exitInfo;
|
|
},
|
|
};
|
|
}
|
|
|
|
async function waitForServer(
|
|
baseUrl: string,
|
|
logs: {
|
|
stdoutLines: string[];
|
|
stderrLines: string[];
|
|
exitInfo?: { code: number | null; signal: NodeJS.Signals | null } | null;
|
|
}
|
|
) {
|
|
const startedAt = Date.now();
|
|
let lastError = "";
|
|
while (Date.now() - startedAt < 120_000) {
|
|
if (logs.exitInfo) {
|
|
throw new Error(
|
|
[
|
|
`OmniRoute exited before it became ready (code=${logs.exitInfo.code}, signal=${logs.exitInfo.signal})`,
|
|
"--- stdout ---",
|
|
...logs.stdoutLines.slice(-40),
|
|
"--- stderr ---",
|
|
...logs.stderrLines.slice(-40),
|
|
].join("\n")
|
|
);
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`${baseUrl}/api/monitoring/health`, {
|
|
signal: AbortSignal.timeout(5_000),
|
|
});
|
|
if (response.ok) return;
|
|
lastError = `HTTP ${response.status}`;
|
|
} catch (error: any) {
|
|
lastError = error instanceof Error ? error.message : String(error);
|
|
}
|
|
await sleep(500);
|
|
}
|
|
|
|
throw new Error(
|
|
[
|
|
`Timed out waiting for OmniRoute to start: ${lastError}`,
|
|
"--- stdout ---",
|
|
...logs.stdoutLines.slice(-40),
|
|
"--- stderr ---",
|
|
...logs.stderrLines.slice(-40),
|
|
].join("\n")
|
|
);
|
|
}
|
|
|
|
async function stopProcess(child: ReturnType<typeof spawn>) {
|
|
if (child.killed) return;
|
|
child.kill("SIGTERM");
|
|
const exited = await Promise.race([
|
|
new Promise<boolean>((resolve) => child.once("exit", () => resolve(true))),
|
|
sleep(5_000).then(() => false),
|
|
]);
|
|
if (!exited && !child.killed) {
|
|
child.kill("SIGKILL");
|
|
await new Promise<void>((resolve) => child.once("exit", () => resolve()));
|
|
}
|
|
}
|
|
|
|
async function postChat(
|
|
baseUrl: string,
|
|
model: string,
|
|
content: string,
|
|
extraHeaders?: Record<string, string>
|
|
) {
|
|
const response = await fetch(`${baseUrl}/api/v1/chat/completions`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", ...extraHeaders },
|
|
body: JSON.stringify({
|
|
model,
|
|
stream: false,
|
|
messages: [{ role: "user", content }],
|
|
}),
|
|
signal: AbortSignal.timeout(30_000),
|
|
});
|
|
const text = await response.text();
|
|
const json = text ? JSON.parse(text) : {};
|
|
return { response, json };
|
|
}
|
|
|
|
async function resetBreakers(url: string) {
|
|
await fetch(`${url}/api/resilience/reset`, {
|
|
method: "POST",
|
|
signal: AbortSignal.timeout(5_000),
|
|
});
|
|
}
|
|
|
|
const serverA = new MockUpstreamServer();
|
|
const serverB = new MockUpstreamServer();
|
|
let app:
|
|
| {
|
|
child: ReturnType<typeof spawn>;
|
|
stdoutLines: string[];
|
|
stderrLines: string[];
|
|
baseUrl: string;
|
|
}
|
|
| undefined;
|
|
|
|
const TOKEN_A = "sk-sys-a";
|
|
const TOKEN_B = "sk-sys-b";
|
|
const TOKEN_A2 = "sk-sys-a2";
|
|
const TOKEN_B2 = "sk-sys-b2";
|
|
|
|
test.before(async () => {
|
|
const baseUrlA = await serverA.start();
|
|
const baseUrlB = await serverB.start();
|
|
|
|
serverA.configureToken(TOKEN_A, {
|
|
defaultResponse: buildCompletion("server A ok", { model: "sys-a/test-model" }),
|
|
});
|
|
serverA.configureToken(TOKEN_A2, {
|
|
defaultResponse: buildCompletion("server A2 ok", { model: "sys-a2/test-model" }),
|
|
});
|
|
serverB.configureToken(TOKEN_B, {
|
|
defaultResponse: buildCompletion("server B ok", { model: "sys-b/test-model" }),
|
|
});
|
|
serverB.configureToken(TOKEN_B2, {
|
|
defaultResponse: buildCompletion("server B2 ok", { model: "sys-b2/test-model" }),
|
|
});
|
|
|
|
const provA = await seedProvider("sys-a", TOKEN_A, baseUrlA);
|
|
const provB = await seedProvider("sys-b", TOKEN_B, baseUrlB);
|
|
const provA2 = await seedProvider("sys-a2", TOKEN_A2, baseUrlA);
|
|
const provB2 = await seedProvider("sys-b2", TOKEN_B2, baseUrlB);
|
|
|
|
await combosDb.createCombo({
|
|
name: "sys-priority",
|
|
strategy: "priority",
|
|
config: { maxRetries: 0, retryDelayMs: 0 },
|
|
models: [provA.model, provB.model],
|
|
});
|
|
await combosDb.createCombo({
|
|
name: "sys-priority-v2",
|
|
strategy: "priority",
|
|
config: { maxRetries: 0, retryDelayMs: 0 },
|
|
models: [provA2.model, provB2.model],
|
|
});
|
|
await combosDb.createCombo({
|
|
name: "sys-priority-fobr",
|
|
strategy: "priority",
|
|
config: { maxRetries: 0, retryDelayMs: 0, failoverBeforeRetry: true },
|
|
models: [provA.model, provB.model],
|
|
});
|
|
await combosDb.createCombo({
|
|
name: "sys-priority-setretry",
|
|
strategy: "priority",
|
|
config: {
|
|
maxRetries: 0,
|
|
retryDelayMs: 0,
|
|
failoverBeforeRetry: true,
|
|
maxSetRetries: 1,
|
|
setRetryDelayMs: 500,
|
|
},
|
|
models: [provA.model, provB.model],
|
|
});
|
|
await combosDb.createCombo({
|
|
name: "sys-same-server",
|
|
strategy: "priority",
|
|
config: { maxRetries: 0, retryDelayMs: 0 },
|
|
models: [provA.model, provA2.model],
|
|
});
|
|
await combosDb.createCombo({
|
|
name: "sys-same-server-fobr",
|
|
strategy: "priority",
|
|
config: { maxRetries: 0, retryDelayMs: 0, failoverBeforeRetry: true },
|
|
models: [provA.model, provA2.model],
|
|
});
|
|
|
|
await combosDb.createCombo({
|
|
name: "sys-single-provider",
|
|
strategy: "priority",
|
|
config: { maxRetries: 0, retryDelayMs: 0 },
|
|
models: ["sys-a/modelA", "sys-a/modelB"],
|
|
});
|
|
await combosDb.createCombo({
|
|
name: "sys-single-provider-fobr",
|
|
strategy: "priority",
|
|
config: { maxRetries: 0, retryDelayMs: 0, failoverBeforeRetry: true },
|
|
models: ["sys-a/modelA", "sys-a/modelB"],
|
|
});
|
|
|
|
await settingsDb.updateSettings({
|
|
resilienceSettings: {
|
|
requestQueue: {
|
|
autoEnableApiKeyProviders: true,
|
|
requestsPerMinute: 120,
|
|
minTimeBetweenRequestsMs: 0,
|
|
concurrentRequests: 4,
|
|
maxWaitMs: 2_000,
|
|
},
|
|
connectionCooldown: {
|
|
oauth: { baseCooldownMs: 500, useUpstreamRetryHints: true, maxBackoffSteps: 3 },
|
|
apikey: { baseCooldownMs: 200, useUpstreamRetryHints: false, maxBackoffSteps: 0 },
|
|
},
|
|
providerBreaker: {
|
|
oauth: { failureThreshold: 3, resetTimeoutMs: 2_000 },
|
|
apikey: { failureThreshold: 2, resetTimeoutMs: 1_500 },
|
|
},
|
|
waitForCooldown: {
|
|
enabled: false,
|
|
maxRetries: 0,
|
|
maxRetryWaitSec: 0,
|
|
},
|
|
},
|
|
requestRetry: 0,
|
|
maxRetryIntervalSec: 0,
|
|
requireLogin: false,
|
|
setupComplete: true,
|
|
});
|
|
|
|
core.closeDbInstance();
|
|
|
|
app = createServerProcess(TEST_DATA_DIR, DASHBOARD_PORT);
|
|
await waitForServer(app.baseUrl, app);
|
|
|
|
const warmup = await postChat(app.baseUrl, "sys-b/test-model", "warm up");
|
|
assert.equal(warmup.response.status, 200, JSON.stringify(warmup.json));
|
|
serverB.resetState(TOKEN_B);
|
|
});
|
|
|
|
test.after(async () => {
|
|
if (app) await stopProcess(app.child);
|
|
await serverA.stop();
|
|
await serverB.stop();
|
|
core.closeDbInstance();
|
|
await fsp.rm(TEST_DATA_DIR, { recursive: true, force: true });
|
|
});
|
|
|
|
test("primary healthy: request routes to Server A only", async () => {
|
|
assert.ok(app);
|
|
serverA.resetState(TOKEN_A);
|
|
serverB.resetState(TOKEN_B);
|
|
|
|
const result = await postChat(app.baseUrl, "sys-priority", "healthy primary");
|
|
|
|
assert.equal(result.response.status, 200, JSON.stringify(result.json));
|
|
assert.equal(result.json.choices[0].message.content, "server A ok");
|
|
assert.equal(result.json.model, "sys-a/test-model");
|
|
assert.equal(serverA.getState(TOKEN_A).hits, 1);
|
|
assert.equal(serverB.getState(TOKEN_B).hits, 0);
|
|
});
|
|
|
|
test("500 Internal Server Error: combo falls back to Server B", async () => {
|
|
assert.ok(app);
|
|
serverA.resetState(TOKEN_A, [buildError(500, "Internal Server Error")]);
|
|
serverB.resetState(TOKEN_B);
|
|
|
|
const result = await postChat(app.baseUrl, "sys-priority", "500 fallback");
|
|
|
|
assert.equal(result.response.status, 200, JSON.stringify(result.json));
|
|
assert.equal(result.json.choices[0].message.content, "server B ok");
|
|
assert.equal(result.json.model, "sys-b/test-model");
|
|
assert.equal(serverA.getState(TOKEN_A).hits, 1);
|
|
assert.equal(serverB.getState(TOKEN_B).hits, 1);
|
|
});
|
|
|
|
test("503 Service Unavailable: combo falls back to Server B", async () => {
|
|
assert.ok(app);
|
|
await resetBreakers(app.baseUrl);
|
|
resetConnectionCooldowns();
|
|
serverA.resetState(TOKEN_A, [buildError(503, "Service Unavailable")]);
|
|
serverB.resetState(TOKEN_B);
|
|
|
|
const result = await postChat(app.baseUrl, "sys-priority", "503 fallback");
|
|
|
|
assert.equal(result.response.status, 200, JSON.stringify(result.json));
|
|
assert.equal(result.json.choices[0].message.content, "server B ok");
|
|
assert.equal(result.json.model, "sys-b/test-model");
|
|
assert.equal(serverA.getState(TOKEN_A).hits, 1);
|
|
assert.equal(serverB.getState(TOKEN_B).hits, 1);
|
|
});
|
|
|
|
test("both servers fail (500): request returns a 5xx error to the client", async () => {
|
|
assert.ok(app);
|
|
await resetBreakers(app.baseUrl);
|
|
resetConnectionCooldowns();
|
|
serverA.resetState(TOKEN_A, [buildError(500, "A is down")]);
|
|
serverB.resetState(TOKEN_B, [buildError(500, "B is down")]);
|
|
|
|
const result = await postChat(app.baseUrl, "sys-priority", "both down");
|
|
|
|
assert.ok(result.response.status >= 500, `expected 5xx, got ${result.response.status}`);
|
|
});
|
|
|
|
test("combo fallback to Server B survives sequential 503 failures from Server A", async () => {
|
|
assert.ok(app);
|
|
await resetBreakers(app.baseUrl);
|
|
resetConnectionCooldowns();
|
|
serverA.resetState(TOKEN_A, [buildError(503, "transient blip"), buildError(503, "second blip")]);
|
|
serverB.resetState(TOKEN_B);
|
|
|
|
const first = await postChat(app.baseUrl, "sys-priority", "seq 503 attempt 1");
|
|
assert.equal(first.response.status, 200, JSON.stringify(first.json));
|
|
assert.equal(first.json.choices[0].message.content, "server B ok");
|
|
assert.equal(first.json.model, "sys-b/test-model");
|
|
|
|
// Wait for the 200ms apikey cooldown to expire so the second request also
|
|
// goes through the full A→B fallback path rather than skipping A entirely.
|
|
await sleep(250);
|
|
|
|
const second = await postChat(app.baseUrl, "sys-priority", "seq 503 attempt 2");
|
|
assert.equal(second.response.status, 200, JSON.stringify(second.json));
|
|
assert.equal(second.json.choices[0].message.content, "server B ok");
|
|
assert.equal(second.json.model, "sys-b/test-model");
|
|
|
|
assert.equal(serverA.getState(TOKEN_A).hits, 2);
|
|
assert.equal(serverB.getState(TOKEN_B).hits, 2);
|
|
});
|
|
|
|
test("429 with Retry-After and wait-for-cooldown: primary retries then falls back to B", async () => {
|
|
assert.ok(app);
|
|
await resetBreakers(app.baseUrl);
|
|
resetConnectionCooldowns();
|
|
serverA.resetState(TOKEN_A, [
|
|
buildError(429, "rate limited, retry after 1s", { "Retry-After": "1" }),
|
|
]);
|
|
serverB.resetState(TOKEN_B);
|
|
|
|
const patchRes = await fetch(`${app.baseUrl}/api/resilience`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
connectionCooldown: {
|
|
apikey: { useUpstreamRetryHints: true, baseCooldownMs: 200 },
|
|
},
|
|
waitForCooldown: { enabled: true, maxRetries: 1, maxRetryWaitSec: 2 },
|
|
}),
|
|
signal: AbortSignal.timeout(10_000),
|
|
});
|
|
});
|
|
|
|
test("failoverBeforeRetry enabled: upstream error triggers immediate failover to next target", async () => {
|
|
assert.ok(app);
|
|
await resetBreakers(app.baseUrl);
|
|
resetConnectionCooldowns();
|
|
serverA.resetState(TOKEN_A, [buildError(429, "rate limited")]);
|
|
serverB.resetState(TOKEN_B);
|
|
|
|
const result = await postChat(app.baseUrl, "sys-priority-fobr", "test failover before retry");
|
|
|
|
assert.equal(result.response.status, 200, JSON.stringify(result.json));
|
|
assert.equal(result.json.choices[0].message.content, "server B ok");
|
|
assert.equal(result.json.model, "sys-b/test-model");
|
|
|
|
// With failoverBeforeRetry=true, A should be hit exactly ONCE (no intra-URL retry)
|
|
assert.equal(serverA.getState(TOKEN_A).hits, 1);
|
|
assert.equal(serverB.getState(TOKEN_B).hits, 1);
|
|
});
|
|
|
|
test("failoverBeforeRetry disabled: 429 triggers executor intra-URL retry, succeeds on retry", async () => {
|
|
assert.ok(app);
|
|
// Full resilience reset so A isn't blocked by residual breaker/cooldown state
|
|
const patchRes = await fetch(`${app.baseUrl}/api/resilience`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
connectionCooldown: {
|
|
apikey: { useUpstreamRetryHints: false, baseCooldownMs: 0, maxBackoffSteps: 0 },
|
|
oauth: { useUpstreamRetryHints: false, baseCooldownMs: 0, maxBackoffSteps: 0 },
|
|
},
|
|
waitForCooldown: { enabled: false, maxRetries: 0, maxRetryWaitSec: 0 },
|
|
}),
|
|
signal: AbortSignal.timeout(10_000),
|
|
});
|
|
assert.equal(patchRes.status, 200);
|
|
await resetBreakers(app.baseUrl);
|
|
resetConnectionCooldowns();
|
|
// Wait for any residual cooldowns to expire
|
|
await sleep(300);
|
|
// One 429, then the default (200) on retry
|
|
serverA.resetState(TOKEN_A, [buildError(429, "rate limited")]);
|
|
serverB.resetState(TOKEN_B);
|
|
|
|
const result = await postChat(app.baseUrl, "sys-priority", "test failover before retry disabled");
|
|
|
|
assert.equal(result.response.status, 200, JSON.stringify(result.json));
|
|
|
|
// With maxRetries=0 the combo does not retry A — it fails over to B immediately.
|
|
assert.equal(result.json.model, "sys-b/test-model");
|
|
|
|
// With failoverBeforeRetry=false, A should be hit TWICE (initial + 1 intra-URL retry).
|
|
// This contrasts with failoverBeforeRetry=true where A is hit exactly ONCE.
|
|
assert.equal(serverA.getState(TOKEN_A).hits, 2);
|
|
});
|
|
|
|
test("maxSetRetries: both A and B fail first pass, A 429 again, B 200 on retry", async () => {
|
|
assert.ok(app);
|
|
// Reset to a clean resilience slate: disable cooldowns, disable waitForCooldown,
|
|
// reset breakers, and clear connection rate_limited_until from previous tests.
|
|
const patchRes = await fetch(`${app.baseUrl}/api/resilience`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
connectionCooldown: {
|
|
apikey: { useUpstreamRetryHints: false, baseCooldownMs: 0, maxBackoffSteps: 0 },
|
|
oauth: { useUpstreamRetryHints: false, baseCooldownMs: 0, maxBackoffSteps: 0 },
|
|
},
|
|
waitForCooldown: { enabled: false, maxRetries: 0, maxRetryWaitSec: 0 },
|
|
}),
|
|
signal: AbortSignal.timeout(10_000),
|
|
});
|
|
assert.equal(patchRes.status, 200);
|
|
await resetBreakers(app.baseUrl);
|
|
resetConnectionCooldowns();
|
|
// Set try 0: A 429, B 500 — both fail
|
|
// Set try 1: A 429, B 200 — B succeeds
|
|
serverA.resetState(TOKEN_A, [buildError(429, "rate limited"), buildError(429, "rate limited")]);
|
|
serverB.resetState(TOKEN_B, [
|
|
buildError(500, "server error"),
|
|
buildCompletion("server B ok on retry", { model: "sys-b/test-model" }),
|
|
]);
|
|
|
|
const result = await postChat(app.baseUrl, "sys-priority-setretry", "test max set retries", {
|
|
"x-internal-test": "combo-health-check",
|
|
});
|
|
|
|
// Set try 0: A 429, B 500 → both fail
|
|
// Set try 1: A 429, B 200 → B succeeds
|
|
assert.equal(result.response.status, 200, JSON.stringify(result.json));
|
|
assert.equal(result.json.choices[0].message.content, "server B ok on retry");
|
|
assert.equal(result.json.model, "sys-b/test-model");
|
|
assert.equal(serverA.getState(TOKEN_A).hits, 2);
|
|
assert.equal(serverB.getState(TOKEN_B).hits, 2);
|
|
|
|
// Restore defaults so other tests are not affected
|
|
await fetch(`${app.baseUrl}/api/resilience`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
connectionCooldown: {
|
|
apikey: { useUpstreamRetryHints: false, baseCooldownMs: 200, maxBackoffSteps: 0 },
|
|
oauth: { useUpstreamRetryHints: true, baseCooldownMs: 500, maxBackoffSteps: 3 },
|
|
},
|
|
}),
|
|
signal: AbortSignal.timeout(10_000),
|
|
});
|
|
});
|
|
|
|
test("same server failoverBeforeRetry disabled: first model 429 retried before trying second", async () => {
|
|
assert.ok(app);
|
|
// Full resilience reset — previous test restored defaults and may have left A cooldown
|
|
const patchRes = await fetch(`${app.baseUrl}/api/resilience`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
connectionCooldown: {
|
|
apikey: { useUpstreamRetryHints: false, baseCooldownMs: 0, maxBackoffSteps: 0 },
|
|
oauth: { useUpstreamRetryHints: false, baseCooldownMs: 0, maxBackoffSteps: 0 },
|
|
},
|
|
waitForCooldown: { enabled: false, maxRetries: 0, maxRetryWaitSec: 0 },
|
|
}),
|
|
signal: AbortSignal.timeout(10_000),
|
|
});
|
|
assert.equal(patchRes.status, 200);
|
|
await sleep(300);
|
|
await resetBreakers(app.baseUrl);
|
|
resetConnectionCooldowns();
|
|
serverA.resetState(TOKEN_A, [buildError(429, "rate limited")]);
|
|
serverA.resetState(TOKEN_A2);
|
|
|
|
const result = await postChat(
|
|
app.baseUrl,
|
|
"sys-same-server",
|
|
"test same server failover disabled"
|
|
);
|
|
|
|
assert.equal(result.response.status, 200, JSON.stringify(result.json));
|
|
|
|
// With maxRetries=0 the combo fails over to A2 rather than retrying A.
|
|
assert.equal(result.json.model, "sys-a2/test-model");
|
|
assert.equal(serverA.getState(TOKEN_A).hits, 2);
|
|
});
|
|
|
|
test("same server failoverBeforeRetry enabled: first model 429 skipped to second immediately", async () => {
|
|
assert.ok(app);
|
|
await sleep(300);
|
|
await resetBreakers(app.baseUrl);
|
|
resetConnectionCooldowns();
|
|
serverA.resetState(TOKEN_A, [buildError(429, "rate limited")]);
|
|
serverA.resetState(TOKEN_A2);
|
|
|
|
const result = await postChat(
|
|
app.baseUrl,
|
|
"sys-same-server-fobr",
|
|
"test same server failover enabled"
|
|
);
|
|
|
|
assert.equal(result.response.status, 200, JSON.stringify(result.json));
|
|
assert.equal(result.json.model, "sys-a2/test-model");
|
|
assert.equal(serverA.getState(TOKEN_A).hits, 1);
|
|
assert.equal(serverA.getState(TOKEN_A2).hits, 1);
|
|
});
|
|
|
|
test("single provider, modelA 500: combo fails over to modelB", async () => {
|
|
assert.ok(app);
|
|
await resetBreakers(app.baseUrl);
|
|
resetConnectionCooldowns();
|
|
serverA.resetState(TOKEN_A, [buildError(500, "model A error")]);
|
|
|
|
const result = await postChat(app.baseUrl, "sys-single-provider", "test modelA 500");
|
|
|
|
assert.equal(result.response.status, 200, JSON.stringify(result.json));
|
|
assert.equal(result.json.model, "sys-a/test-model");
|
|
assert.equal(serverA.getState(TOKEN_A).hits, 2);
|
|
});
|
|
|
|
test("single provider, modelA 503: combo fails over to modelB", async () => {
|
|
assert.ok(app);
|
|
await resetBreakers(app.baseUrl);
|
|
resetConnectionCooldowns();
|
|
serverA.resetState(TOKEN_A, [buildError(503, "Service Unavailable")]);
|
|
|
|
const result = await postChat(app.baseUrl, "sys-single-provider", "test modelA 503");
|
|
|
|
assert.equal(result.response.status, 200, JSON.stringify(result.json));
|
|
assert.equal(result.json.model, "sys-a/test-model");
|
|
assert.equal(serverA.getState(TOKEN_A).hits, 2);
|
|
});
|
|
|
|
test("single provider, modelA 429 with fobr: immediate failover to modelB", async () => {
|
|
assert.ok(app);
|
|
await resetBreakers(app.baseUrl);
|
|
resetConnectionCooldowns();
|
|
serverA.resetState(TOKEN_A, [buildError(429, "rate limited")]);
|
|
|
|
const result = await postChat(app.baseUrl, "sys-single-provider-fobr", "test fobr");
|
|
|
|
assert.equal(result.response.status, 200, JSON.stringify(result.json));
|
|
assert.equal(result.json.model, "sys-a/test-model");
|
|
assert.equal(serverA.getState(TOKEN_A).hits, 2);
|
|
});
|
|
|
|
test("single provider, modelA 500 with fobr: modelA retry", async () => {
|
|
assert.ok(app);
|
|
await resetBreakers(app.baseUrl);
|
|
resetConnectionCooldowns();
|
|
serverA.resetState(TOKEN_A, [buildError(500, "Oops!")]);
|
|
|
|
const result = await postChat(app.baseUrl, "sys-single-provider-fobr", "test fobr");
|
|
|
|
assert.equal(result.response.status, 200, JSON.stringify(result.json));
|
|
assert.equal(result.json.model, "sys-a/test-model");
|
|
assert.equal(serverA.getState(TOKEN_A).hits, 2);
|
|
});
|
|
|
|
test("single provider, both models fail: request returns 5xx to client", async () => {
|
|
assert.ok(app);
|
|
await resetBreakers(app.baseUrl);
|
|
resetConnectionCooldowns();
|
|
serverA.resetState(TOKEN_A, [buildError(500, "modelA down"), buildError(500, "modelB down")]);
|
|
|
|
const result = await postChat(app.baseUrl, "sys-single-provider", "both down");
|
|
|
|
assert.ok(result.response.status >= 500, `expected 5xx, got ${result.response.status}`);
|
|
assert.equal(serverA.getState(TOKEN_A).hits, 2);
|
|
});
|