| import { test, beforeEach } from "node:test";
|
| import assert from "node:assert/strict";
|
| import {
|
| selfFetchWithRetry,
|
| ensureLoopbackServerReady,
|
| __resetLoopbackReadinessForTests,
|
| } from "../../../src/app/api/providers/[id]/sync-models/route.ts";
|
|
|
|
|
|
|
|
|
| test("self-fetch retries with backoff and succeeds on attempt 3", async () => {
|
| let attempts = 0;
|
| const fetchMock: typeof fetch = async () => {
|
| attempts++;
|
| if (attempts < 3) {
|
| throw new Error("fetch failed");
|
| }
|
| return new Response(JSON.stringify({ models: [{ id: "model-1" }] }), { status: 200 });
|
| };
|
|
|
| let inProcCalls = 0;
|
| const inProcMock = async () => {
|
| inProcCalls++;
|
| return new Response(JSON.stringify({ models: [] }), { status: 200 });
|
| };
|
|
|
| const result = await selfFetchWithRetry("http://127.0.0.1:20128/api/providers/conn-1/models", {
|
| fetch: fetchMock,
|
| maxRetries: 5,
|
| backoffMs: 5,
|
| inProcessFallback: inProcMock,
|
| skipReadinessGate: true,
|
| });
|
|
|
| assert.equal(attempts, 3, "should have retried twice before succeeding on attempt 3");
|
| assert.equal(inProcCalls, 0, "should not have called in-process route");
|
| assert.equal(result.ok, true, "response should be ok");
|
| });
|
|
|
|
|
|
|
|
|
| test("self-fetch falls back to in-process route after maxRetries failures", async () => {
|
| let attempts = 0;
|
| const fetchMock: typeof fetch = async () => {
|
| attempts++;
|
| throw new Error("fetch failed");
|
| };
|
|
|
| let inProcCalls = 0;
|
| const inProcMock = async () => {
|
| inProcCalls++;
|
| return new Response(JSON.stringify({ models: [{ id: "in-proc-model" }] }), { status: 200 });
|
| };
|
|
|
| const result = await selfFetchWithRetry("http://127.0.0.1:20128/api/providers/conn-2/models", {
|
| fetch: fetchMock,
|
| maxRetries: 3,
|
| backoffMs: 5,
|
| connectionId: "conn-2",
|
| inProcessFallback: inProcMock,
|
| skipReadinessGate: true,
|
| });
|
|
|
| assert.equal(attempts, 3, "should retry exactly maxRetries times");
|
| assert.equal(inProcCalls, 1, "should fall back to in-process exactly once");
|
| const body = await result.json();
|
| assert.equal(body.models[0].id, "in-proc-model");
|
| });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| test("self-fetch returns HTTP error responses immediately without retrying", async () => {
|
|
|
| {
|
| let attempts = 0;
|
| const fetchMock: typeof fetch = async () => {
|
| attempts++;
|
| return new Response("server error", { status: 503 });
|
| };
|
| const inProcMock = async () => new Response(JSON.stringify({ models: [] }), { status: 200 });
|
|
|
| const res = await selfFetchWithRetry("http://127.0.0.1:20128/api/providers/conn-3/models", {
|
| fetch: fetchMock,
|
| maxRetries: 5,
|
| backoffMs: 5,
|
| inProcessFallback: inProcMock,
|
| skipReadinessGate: true,
|
| });
|
|
|
| assert.equal(attempts, 1, "5xx HTTP response should NOT retry (got " + attempts + ")");
|
| assert.equal(res.status, 503, "should propagate the 503 response as-is");
|
| }
|
|
|
|
|
| {
|
| let attempts = 0;
|
| const fetchMock: typeof fetch = async () => {
|
| attempts++;
|
| return new Response("not found", { status: 404 });
|
| };
|
| const inProcMock = async () => new Response(JSON.stringify({ models: [] }), { status: 200 });
|
|
|
| const res = await selfFetchWithRetry("http://127.0.0.1:20128/api/providers/conn-4/models", {
|
| fetch: fetchMock,
|
| maxRetries: 5,
|
| backoffMs: 5,
|
| inProcessFallback: inProcMock,
|
| skipReadinessGate: true,
|
| });
|
|
|
| assert.equal(attempts, 1, "4xx HTTP response should NOT retry (got " + attempts + ")");
|
| assert.equal(res.status, 404, "should propagate the 404 response as-is");
|
| }
|
| });
|
|
|
|
|
|
|
|
|
|
|
| test("ensureLoopbackServerReady: 17 concurrent callers trigger exactly ONE probe sequence", async () => {
|
| __resetLoopbackReadinessForTests();
|
| let probeCalls = 0;
|
| let serverIsUp = false;
|
|
|
| setTimeout(() => {
|
| serverIsUp = true;
|
| }, 30);
|
| const mockFetch = async (_url) => {
|
| probeCalls++;
|
| if (!serverIsUp) throw new Error("fetch failed");
|
| return new Response("", { status: 200 });
|
| };
|
|
|
| await Promise.all(
|
| Array.from({ length: 17 }, () =>
|
| ensureLoopbackServerReady({ fetch: mockFetch, pollMs: 5, maxWaitMs: 1000 })
|
| )
|
| );
|
|
|
|
|
|
|
|
|
| assert.ok(probeCalls <= 15, "single shared probe expected <=15 attempts, got " + probeCalls);
|
| });
|
|
|
| test("ensureLoopbackServerReady: rejects after maxWaitMs with consistent network errors", async () => {
|
| __resetLoopbackReadinessForTests();
|
| const mockFetch = async (_url) => {
|
| throw new Error("ECONNREFUSED");
|
| };
|
| await assert.rejects(
|
| () =>
|
| ensureLoopbackServerReady({
|
| fetch: mockFetch,
|
| maxWaitMs: 50,
|
| pollMs: 10,
|
| }),
|
| /loopback server not ready/
|
| );
|
| });
|
|
|
| test("ensureLoopbackServerReady: resolves on 4xx (any HTTP status confirms server is up)", async () => {
|
| __resetLoopbackReadinessForTests();
|
| let calls = 0;
|
| const mockFetch = async (_url) => {
|
| calls++;
|
| return new Response("not found", { status: 404 });
|
| };
|
|
|
| await ensureLoopbackServerReady({ fetch: mockFetch, maxWaitMs: 500, pollMs: 10 });
|
| assert.equal(calls, 1, "resolved after exactly 1 probe (server immediately responded 404)");
|
| });
|
|
|
| test("selfFetchWithRetry with gate: 17 concurrent callers produce one probe + one fetch each", async () => {
|
| __resetLoopbackReadinessForTests();
|
| let probeCalls = 0;
|
| let modelFetchCalls = 0;
|
| let serverIsUp = false;
|
| setTimeout(() => {
|
| serverIsUp = true;
|
| }, 30);
|
|
|
| const mockFetch = async (url) => {
|
| const isReadinessProbe = url.includes("__readiness_probe__");
|
| if (isReadinessProbe) {
|
| probeCalls++;
|
| if (!serverIsUp) throw new Error("fetch failed");
|
| return new Response("", { status: 404 });
|
| }
|
| modelFetchCalls++;
|
| if (!serverIsUp) throw new Error("fetch failed");
|
| return new Response(JSON.stringify({ models: [{ id: "model-x" }] }), { status: 200 });
|
| };
|
|
|
| await Promise.all(
|
| Array.from({ length: 17 }, (_, i) =>
|
| selfFetchWithRetry("http://127.0.0.1:20128/api/providers/conn-" + i + "/models", {
|
| fetch: mockFetch,
|
| maxRetries: 3,
|
| backoffMs: 5,
|
| })
|
| )
|
| );
|
|
|
|
|
| assert.equal(modelFetchCalls, 17, "each connection fetches its own models exactly once");
|
|
|
| assert.ok(probeCalls <= 15, "probe should be shared, got " + probeCalls + " attempts");
|
| });
|
|
|
|
|
| test("sanity: without readiness gate, 17 callers retry independently (amplification confirmed)", async () => {
|
| __resetLoopbackReadinessForTests();
|
| let modelFetchCalls = 0;
|
| let serverIsUp = false;
|
| setTimeout(() => {
|
| serverIsUp = true;
|
| }, 30);
|
|
|
| const mockFetch = async (_url) => {
|
| modelFetchCalls++;
|
| if (!serverIsUp) throw new Error("fetch failed");
|
| return new Response(JSON.stringify({ models: [{ id: "model-x" }] }), { status: 200 });
|
| };
|
|
|
| await Promise.all(
|
| Array.from({ length: 17 }, (_, i) =>
|
| selfFetchWithRetry("http://127.0.0.1:20128/api/providers/conn-" + i + "/models", {
|
| fetch: mockFetch,
|
| maxRetries: 5,
|
| backoffMs: 5,
|
| skipReadinessGate: true,
|
| })
|
| )
|
| );
|
|
|
|
|
|
|
| assert.ok(
|
| modelFetchCalls > 17,
|
| "without gate, callers retry independently, got " + modelFetchCalls + " (expected >17)"
|
| );
|
| });
|
|
|