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((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) { if (child.killed) return; child.kill("SIGTERM"); const exited = await Promise.race([ new Promise((resolve) => child.once("exit", () => resolve(true))), sleep(5_000).then(() => false), ]); if (!exited && !child.killed) { child.kill("SIGKILL"); await new Promise((resolve) => child.once("exit", () => resolve())); } } async function postChat( baseUrl: string, model: string, content: string, extraHeaders?: Record ) { 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; 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); });