proxy-forwarder-retry-limit.test.ts 33 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084
  1. import { beforeEach, describe, expect, test, vi } from "vitest";
  2. import { resolveEndpointPolicy } from "@/app/v1/_lib/proxy/endpoint-policy";
  3. import { V1_ENDPOINT_PATHS } from "@/app/v1/_lib/proxy/endpoint-paths";
  4. const mocks = vi.hoisted(() => {
  5. return {
  6. getPreferredProviderEndpoints: vi.fn(),
  7. recordEndpointSuccess: vi.fn(async () => {}),
  8. recordEndpointFailure: vi.fn(async () => {}),
  9. recordSuccess: vi.fn(),
  10. recordFailure: vi.fn(async () => {}),
  11. getCircuitState: vi.fn(() => "closed"),
  12. getProviderHealthInfo: vi.fn(async () => ({
  13. health: { failureCount: 0 },
  14. config: { failureThreshold: 3 },
  15. })),
  16. isVendorTypeCircuitOpen: vi.fn(async () => false),
  17. recordVendorTypeAllEndpointsTimeout: vi.fn(async () => {}),
  18. findAllProviders: vi.fn(async () => []),
  19. getCachedProviders: vi.fn(async () => []),
  20. };
  21. });
  22. vi.mock("@/lib/logger", () => ({
  23. logger: {
  24. debug: vi.fn(),
  25. info: vi.fn(),
  26. warn: vi.fn(),
  27. trace: vi.fn(),
  28. error: vi.fn(),
  29. fatal: vi.fn(),
  30. },
  31. }));
  32. vi.mock("@/lib/provider-endpoints/endpoint-selector", () => ({
  33. getPreferredProviderEndpoints: mocks.getPreferredProviderEndpoints,
  34. }));
  35. vi.mock("@/lib/endpoint-circuit-breaker", () => ({
  36. recordEndpointSuccess: mocks.recordEndpointSuccess,
  37. recordEndpointFailure: mocks.recordEndpointFailure,
  38. }));
  39. vi.mock("@/lib/circuit-breaker", () => ({
  40. getCircuitState: mocks.getCircuitState,
  41. getProviderHealthInfo: mocks.getProviderHealthInfo,
  42. recordSuccess: mocks.recordSuccess,
  43. recordFailure: mocks.recordFailure,
  44. }));
  45. vi.mock("@/lib/vendor-type-circuit-breaker", () => ({
  46. isVendorTypeCircuitOpen: mocks.isVendorTypeCircuitOpen,
  47. recordVendorTypeAllEndpointsTimeout: mocks.recordVendorTypeAllEndpointsTimeout,
  48. }));
  49. vi.mock("@/repository/provider", () => ({
  50. findAllProviders: mocks.findAllProviders,
  51. }));
  52. vi.mock("@/lib/cache/provider-cache", () => ({
  53. getCachedProviders: mocks.getCachedProviders,
  54. }));
  55. vi.mock("@/app/v1/_lib/proxy/errors", async (importOriginal) => {
  56. const actual = await importOriginal<typeof import("@/app/v1/_lib/proxy/errors")>();
  57. return {
  58. ...actual,
  59. categorizeErrorAsync: vi.fn(async () => actual.ErrorCategory.PROVIDER_ERROR),
  60. };
  61. });
  62. import { ProxyForwarder } from "@/app/v1/_lib/proxy/forwarder";
  63. import { ProxyError, ErrorCategory, categorizeErrorAsync } from "@/app/v1/_lib/proxy/errors";
  64. import { ProxySession } from "@/app/v1/_lib/proxy/session";
  65. import type { Provider, ProviderEndpoint, ProviderType } from "@/types/provider";
  66. function makeEndpoint(input: {
  67. id: number;
  68. vendorId: number;
  69. providerType: ProviderType;
  70. url: string;
  71. lastProbeLatencyMs?: number | null;
  72. }): ProviderEndpoint {
  73. const now = new Date("2026-01-01T00:00:00.000Z");
  74. return {
  75. id: input.id,
  76. vendorId: input.vendorId,
  77. providerType: input.providerType,
  78. url: input.url,
  79. label: null,
  80. sortOrder: 0,
  81. isEnabled: true,
  82. lastProbedAt: null,
  83. lastProbeOk: true,
  84. lastProbeStatusCode: 200,
  85. lastProbeLatencyMs: input.lastProbeLatencyMs ?? null,
  86. lastProbeErrorType: null,
  87. lastProbeErrorMessage: null,
  88. createdAt: now,
  89. updatedAt: now,
  90. deletedAt: null,
  91. };
  92. }
  93. function createProvider(overrides: Partial<Provider> = {}): Provider {
  94. return {
  95. id: 1,
  96. name: "test-provider",
  97. url: "https://provider.example.com",
  98. key: "test-key",
  99. providerVendorId: 123,
  100. isEnabled: true,
  101. weight: 1,
  102. priority: 0,
  103. costMultiplier: 1,
  104. groupTag: null,
  105. providerType: "claude",
  106. preserveClientIp: false,
  107. modelRedirects: null,
  108. allowedModels: null,
  109. mcpPassthroughType: "none",
  110. mcpPassthroughUrl: null,
  111. limit5hUsd: null,
  112. limitDailyUsd: null,
  113. dailyResetMode: "fixed",
  114. dailyResetTime: "00:00",
  115. limitWeeklyUsd: null,
  116. limitMonthlyUsd: null,
  117. limitTotalUsd: null,
  118. totalCostResetAt: null,
  119. limitConcurrentSessions: 0,
  120. maxRetryAttempts: null,
  121. circuitBreakerFailureThreshold: 5,
  122. circuitBreakerOpenDuration: 1_800_000,
  123. circuitBreakerHalfOpenSuccessThreshold: 2,
  124. proxyUrl: null,
  125. proxyFallbackToDirect: false,
  126. firstByteTimeoutStreamingMs: 30_000,
  127. streamingIdleTimeoutMs: 10_000,
  128. requestTimeoutNonStreamingMs: 600_000,
  129. websiteUrl: null,
  130. faviconUrl: null,
  131. cacheTtlPreference: null,
  132. context1mPreference: null,
  133. codexReasoningEffortPreference: null,
  134. codexReasoningSummaryPreference: null,
  135. codexTextVerbosityPreference: null,
  136. codexParallelToolCallsPreference: null,
  137. tpm: 0,
  138. rpm: 0,
  139. rpd: 0,
  140. cc: 0,
  141. createdAt: new Date(),
  142. updatedAt: new Date(),
  143. deletedAt: null,
  144. ...overrides,
  145. };
  146. }
  147. function createSession(requestUrl: URL = new URL("https://example.com/v1/messages")): ProxySession {
  148. const headers = new Headers();
  149. const session = Object.create(ProxySession.prototype);
  150. Object.assign(session, {
  151. startTime: Date.now(),
  152. method: "POST",
  153. requestUrl,
  154. headers,
  155. originalHeaders: new Headers(headers),
  156. headerLog: JSON.stringify(Object.fromEntries(headers.entries())),
  157. request: {
  158. model: "claude-3-opus",
  159. log: "(test)",
  160. message: {
  161. model: "claude-3-opus",
  162. messages: [{ role: "user", content: "hello" }],
  163. },
  164. },
  165. userAgent: null,
  166. context: null,
  167. clientAbortSignal: null,
  168. userName: "test-user",
  169. authState: { success: true, user: null, key: null, apiKey: null },
  170. provider: null,
  171. messageContext: null,
  172. sessionId: null,
  173. requestSequence: 1,
  174. originalFormat: "claude",
  175. providerType: null,
  176. originalModelName: null,
  177. originalUrlPathname: null,
  178. providerChain: [],
  179. endpointPolicy: resolveEndpointPolicy(requestUrl.pathname),
  180. cacheTtlResolved: null,
  181. context1mApplied: false,
  182. specialSettings: [],
  183. cachedPriceData: undefined,
  184. cachedBillingModelSource: undefined,
  185. providersSnapshot: [],
  186. isHeaderModified: () => false,
  187. });
  188. return session as ProxySession;
  189. }
  190. describe("ProxyForwarder - raw passthrough policy parity (T5 RED)", () => {
  191. beforeEach(() => {
  192. vi.clearAllMocks();
  193. vi.mocked(categorizeErrorAsync).mockResolvedValue(ErrorCategory.PROVIDER_ERROR);
  194. });
  195. test.each([
  196. V1_ENDPOINT_PATHS.MESSAGES_COUNT_TOKENS,
  197. V1_ENDPOINT_PATHS.RESPONSES_COMPACT,
  198. ])("RED: %s 失败时都应统一为 no-retry/no-switch/no-circuit(Wave2 未实现前应失败)", async (pathname) => {
  199. vi.useFakeTimers();
  200. try {
  201. const session = createSession(new URL(`https://example.com${pathname}`));
  202. const provider = createProvider({
  203. providerType: "claude",
  204. providerVendorId: 123,
  205. maxRetryAttempts: 3,
  206. });
  207. session.setProvider(provider);
  208. mocks.getPreferredProviderEndpoints.mockResolvedValue([
  209. makeEndpoint({
  210. id: 1,
  211. vendorId: 123,
  212. providerType: "claude",
  213. url: "https://ep1.example.com",
  214. }),
  215. makeEndpoint({
  216. id: 2,
  217. vendorId: 123,
  218. providerType: "claude",
  219. url: "https://ep2.example.com",
  220. }),
  221. ]);
  222. const doForward = vi.spyOn(
  223. ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown },
  224. "doForward"
  225. );
  226. const selectAlternative = vi.spyOn(
  227. ProxyForwarder as unknown as { selectAlternative: (...args: unknown[]) => unknown },
  228. "selectAlternative"
  229. );
  230. doForward.mockImplementation(async () => {
  231. throw new ProxyError("upstream failed", 500);
  232. });
  233. const sendPromise = ProxyForwarder.send(session);
  234. let caughtError: Error | null = null;
  235. sendPromise.catch((error) => {
  236. caughtError = error as Error;
  237. });
  238. await vi.runAllTimersAsync();
  239. expect(caughtError).toBeInstanceOf(ProxyError);
  240. expect(doForward).toHaveBeenCalledTimes(1);
  241. expect(selectAlternative).not.toHaveBeenCalled();
  242. expect(mocks.recordFailure).not.toHaveBeenCalled();
  243. } finally {
  244. vi.useRealTimers();
  245. }
  246. });
  247. });
  248. describe("ProxyForwarder - retry limit enforcement", () => {
  249. beforeEach(() => {
  250. vi.clearAllMocks();
  251. });
  252. test("endpoints > maxRetry: should only use top N lowest-latency endpoints", async () => {
  253. vi.useFakeTimers();
  254. try {
  255. const session = createSession();
  256. // Configure provider with maxRetryAttempts=2 but 4 endpoints available
  257. const provider = createProvider({
  258. providerType: "claude",
  259. providerVendorId: 123,
  260. maxRetryAttempts: 2,
  261. });
  262. session.setProvider(provider);
  263. // Return 4 endpoints sorted by latency (lowest first)
  264. mocks.getPreferredProviderEndpoints.mockResolvedValue([
  265. makeEndpoint({
  266. id: 1,
  267. vendorId: 123,
  268. providerType: "claude",
  269. url: "https://ep1.example.com",
  270. lastProbeLatencyMs: 100,
  271. }),
  272. makeEndpoint({
  273. id: 2,
  274. vendorId: 123,
  275. providerType: "claude",
  276. url: "https://ep2.example.com",
  277. lastProbeLatencyMs: 200,
  278. }),
  279. makeEndpoint({
  280. id: 3,
  281. vendorId: 123,
  282. providerType: "claude",
  283. url: "https://ep3.example.com",
  284. lastProbeLatencyMs: 300,
  285. }),
  286. makeEndpoint({
  287. id: 4,
  288. vendorId: 123,
  289. providerType: "claude",
  290. url: "https://ep4.example.com",
  291. lastProbeLatencyMs: 400,
  292. }),
  293. ]);
  294. // Use SYSTEM_ERROR to trigger endpoint switching on retry
  295. vi.mocked(categorizeErrorAsync).mockResolvedValue(ErrorCategory.SYSTEM_ERROR);
  296. const doForward = vi.spyOn(
  297. ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown },
  298. "doForward"
  299. );
  300. // Create a network-like error
  301. const networkError = new TypeError("fetch failed");
  302. Object.assign(networkError, { code: "ECONNREFUSED" });
  303. // First attempt fails with network error, second succeeds
  304. doForward.mockImplementationOnce(async () => {
  305. throw networkError;
  306. });
  307. doForward.mockResolvedValueOnce(
  308. new Response("{}", {
  309. status: 200,
  310. headers: { "content-type": "application/json", "content-length": "2" },
  311. })
  312. );
  313. const sendPromise = ProxyForwarder.send(session);
  314. await vi.advanceTimersByTimeAsync(100);
  315. const response = await sendPromise;
  316. expect(response.status).toBe(200);
  317. // Should only call doForward twice (maxRetryAttempts=2)
  318. expect(doForward).toHaveBeenCalledTimes(2);
  319. const chain = session.getProviderChain();
  320. expect(chain).toHaveLength(2);
  321. // First attempt should use endpoint 1 (lowest latency)
  322. expect(chain[0].endpointId).toBe(1);
  323. expect(chain[0].attemptNumber).toBe(1);
  324. // Second attempt should use endpoint 2 (SYSTEM_ERROR advances endpoint)
  325. expect(chain[1].endpointId).toBe(2);
  326. expect(chain[1].attemptNumber).toBe(2);
  327. // Endpoints 3 and 4 should NOT be used
  328. } finally {
  329. vi.useRealTimers();
  330. }
  331. });
  332. test("endpoints < maxRetry: should stay at last endpoint after exhausting all (no wrap-around)", async () => {
  333. vi.useFakeTimers();
  334. try {
  335. const session = createSession();
  336. // Configure provider with maxRetryAttempts=5 but only 2 endpoints
  337. const provider = createProvider({
  338. providerType: "claude",
  339. providerVendorId: 123,
  340. maxRetryAttempts: 5,
  341. });
  342. session.setProvider(provider);
  343. mocks.getPreferredProviderEndpoints.mockResolvedValue([
  344. makeEndpoint({
  345. id: 1,
  346. vendorId: 123,
  347. providerType: "claude",
  348. url: "https://ep1.example.com",
  349. lastProbeLatencyMs: 100,
  350. }),
  351. makeEndpoint({
  352. id: 2,
  353. vendorId: 123,
  354. providerType: "claude",
  355. url: "https://ep2.example.com",
  356. lastProbeLatencyMs: 200,
  357. }),
  358. ]);
  359. // Use SYSTEM_ERROR to trigger endpoint switching
  360. vi.mocked(categorizeErrorAsync).mockResolvedValue(ErrorCategory.SYSTEM_ERROR);
  361. const doForward = vi.spyOn(
  362. ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown },
  363. "doForward"
  364. );
  365. const networkError = new TypeError("fetch failed");
  366. Object.assign(networkError, { code: "ECONNREFUSED" });
  367. // All attempts fail except the last one
  368. doForward.mockImplementation(async () => {
  369. throw networkError;
  370. });
  371. // 5th attempt succeeds
  372. doForward.mockImplementationOnce(async () => {
  373. throw networkError;
  374. });
  375. doForward.mockImplementationOnce(async () => {
  376. throw networkError;
  377. });
  378. doForward.mockImplementationOnce(async () => {
  379. throw networkError;
  380. });
  381. doForward.mockImplementationOnce(async () => {
  382. throw networkError;
  383. });
  384. doForward.mockResolvedValueOnce(
  385. new Response("{}", {
  386. status: 200,
  387. headers: { "content-type": "application/json", "content-length": "2" },
  388. })
  389. );
  390. const sendPromise = ProxyForwarder.send(session);
  391. await vi.advanceTimersByTimeAsync(500);
  392. const response = await sendPromise;
  393. expect(response.status).toBe(200);
  394. // Should call doForward 5 times (maxRetryAttempts=5)
  395. expect(doForward).toHaveBeenCalledTimes(5);
  396. const chain = session.getProviderChain();
  397. expect(chain).toHaveLength(5);
  398. // Verify NO wrap-around pattern: 1, 2, 2, 2, 2 (stays at last endpoint)
  399. expect(chain[0].endpointId).toBe(1);
  400. expect(chain[1].endpointId).toBe(2);
  401. expect(chain[2].endpointId).toBe(2); // stays at endpoint 2
  402. expect(chain[3].endpointId).toBe(2);
  403. expect(chain[4].endpointId).toBe(2);
  404. } finally {
  405. vi.useRealTimers();
  406. }
  407. });
  408. test("endpoints = maxRetry: each endpoint should be tried exactly once with SYSTEM_ERROR", async () => {
  409. vi.useFakeTimers();
  410. try {
  411. const session = createSession();
  412. // Configure provider with maxRetryAttempts=3 and 3 endpoints
  413. const provider = createProvider({
  414. providerType: "claude",
  415. providerVendorId: 123,
  416. maxRetryAttempts: 3,
  417. });
  418. session.setProvider(provider);
  419. mocks.getPreferredProviderEndpoints.mockResolvedValue([
  420. makeEndpoint({
  421. id: 1,
  422. vendorId: 123,
  423. providerType: "claude",
  424. url: "https://ep1.example.com",
  425. lastProbeLatencyMs: 100,
  426. }),
  427. makeEndpoint({
  428. id: 2,
  429. vendorId: 123,
  430. providerType: "claude",
  431. url: "https://ep2.example.com",
  432. lastProbeLatencyMs: 200,
  433. }),
  434. makeEndpoint({
  435. id: 3,
  436. vendorId: 123,
  437. providerType: "claude",
  438. url: "https://ep3.example.com",
  439. lastProbeLatencyMs: 300,
  440. }),
  441. ]);
  442. // Use SYSTEM_ERROR to trigger endpoint switching
  443. vi.mocked(categorizeErrorAsync).mockResolvedValue(ErrorCategory.SYSTEM_ERROR);
  444. const doForward = vi.spyOn(
  445. ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown },
  446. "doForward"
  447. );
  448. const networkError = new TypeError("fetch failed");
  449. Object.assign(networkError, { code: "ECONNREFUSED" });
  450. // First two fail with network error, third succeeds
  451. doForward.mockImplementationOnce(async () => {
  452. throw networkError;
  453. });
  454. doForward.mockImplementationOnce(async () => {
  455. throw networkError;
  456. });
  457. doForward.mockResolvedValueOnce(
  458. new Response("{}", {
  459. status: 200,
  460. headers: { "content-type": "application/json", "content-length": "2" },
  461. })
  462. );
  463. const sendPromise = ProxyForwarder.send(session);
  464. await vi.advanceTimersByTimeAsync(300);
  465. const response = await sendPromise;
  466. expect(response.status).toBe(200);
  467. expect(doForward).toHaveBeenCalledTimes(3);
  468. const chain = session.getProviderChain();
  469. expect(chain).toHaveLength(3);
  470. // Each endpoint tried exactly once (SYSTEM_ERROR advances endpoint)
  471. expect(chain[0].endpointId).toBe(1);
  472. expect(chain[1].endpointId).toBe(2);
  473. expect(chain[2].endpointId).toBe(3);
  474. } finally {
  475. vi.useRealTimers();
  476. }
  477. });
  478. test("MCP request: should use provider.url only, ignore vendor endpoints", async () => {
  479. const session = createSession(new URL("https://example.com/mcp/custom-endpoint"));
  480. const provider = createProvider({
  481. providerType: "claude",
  482. providerVendorId: 123,
  483. maxRetryAttempts: 2,
  484. url: "https://provider.example.com/mcp",
  485. });
  486. session.setProvider(provider);
  487. // Even if endpoints are available, MCP should not use them
  488. mocks.getPreferredProviderEndpoints.mockResolvedValue([
  489. makeEndpoint({
  490. id: 1,
  491. vendorId: 123,
  492. providerType: "claude",
  493. url: "https://ep1.example.com",
  494. }),
  495. makeEndpoint({
  496. id: 2,
  497. vendorId: 123,
  498. providerType: "claude",
  499. url: "https://ep2.example.com",
  500. }),
  501. ]);
  502. const doForward = vi.spyOn(
  503. ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown },
  504. "doForward"
  505. );
  506. doForward.mockResolvedValueOnce(
  507. new Response("{}", {
  508. status: 200,
  509. headers: { "content-type": "application/json", "content-length": "2" },
  510. })
  511. );
  512. const response = await ProxyForwarder.send(session);
  513. expect(response.status).toBe(200);
  514. // getPreferredProviderEndpoints should NOT be called for MCP requests
  515. expect(mocks.getPreferredProviderEndpoints).not.toHaveBeenCalled();
  516. const chain = session.getProviderChain();
  517. expect(chain).toHaveLength(1);
  518. // endpointId should be null (using provider.url)
  519. expect(chain[0].endpointId).toBeNull();
  520. });
  521. test("no vendor endpoints: should use provider.url with configured maxRetry", async () => {
  522. vi.useFakeTimers();
  523. try {
  524. const session = createSession();
  525. // Provider without vendorId
  526. const provider = createProvider({
  527. providerType: "claude",
  528. providerVendorId: null as unknown as number,
  529. maxRetryAttempts: 3,
  530. url: "https://provider.example.com",
  531. });
  532. session.setProvider(provider);
  533. const doForward = vi.spyOn(
  534. ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown },
  535. "doForward"
  536. );
  537. // First two fail, third succeeds
  538. doForward.mockImplementationOnce(async () => {
  539. throw new ProxyError("failed", 500);
  540. });
  541. doForward.mockImplementationOnce(async () => {
  542. throw new ProxyError("failed", 500);
  543. });
  544. doForward.mockResolvedValueOnce(
  545. new Response("{}", {
  546. status: 200,
  547. headers: { "content-type": "application/json", "content-length": "2" },
  548. })
  549. );
  550. const sendPromise = ProxyForwarder.send(session);
  551. await vi.advanceTimersByTimeAsync(300);
  552. const response = await sendPromise;
  553. expect(response.status).toBe(200);
  554. // Should retry up to maxRetryAttempts times
  555. expect(doForward).toHaveBeenCalledTimes(3);
  556. // getPreferredProviderEndpoints should NOT be called (no vendorId)
  557. expect(mocks.getPreferredProviderEndpoints).not.toHaveBeenCalled();
  558. const chain = session.getProviderChain();
  559. expect(chain).toHaveLength(3);
  560. // All attempts should use provider.url (endpointId=null)
  561. expect(chain[0].endpointId).toBeNull();
  562. expect(chain[1].endpointId).toBeNull();
  563. expect(chain[2].endpointId).toBeNull();
  564. } finally {
  565. vi.useRealTimers();
  566. }
  567. });
  568. test("all retries exhausted: should not exceed maxRetryAttempts", async () => {
  569. vi.useFakeTimers();
  570. try {
  571. const session = createSession();
  572. const provider = createProvider({
  573. providerType: "claude",
  574. providerVendorId: 123,
  575. maxRetryAttempts: 2,
  576. });
  577. session.setProvider(provider);
  578. // 4 endpoints available but maxRetry=2
  579. mocks.getPreferredProviderEndpoints.mockResolvedValue([
  580. makeEndpoint({
  581. id: 1,
  582. vendorId: 123,
  583. providerType: "claude",
  584. url: "https://ep1.example.com",
  585. lastProbeLatencyMs: 100,
  586. }),
  587. makeEndpoint({
  588. id: 2,
  589. vendorId: 123,
  590. providerType: "claude",
  591. url: "https://ep2.example.com",
  592. lastProbeLatencyMs: 200,
  593. }),
  594. makeEndpoint({
  595. id: 3,
  596. vendorId: 123,
  597. providerType: "claude",
  598. url: "https://ep3.example.com",
  599. lastProbeLatencyMs: 300,
  600. }),
  601. makeEndpoint({
  602. id: 4,
  603. vendorId: 123,
  604. providerType: "claude",
  605. url: "https://ep4.example.com",
  606. lastProbeLatencyMs: 400,
  607. }),
  608. ]);
  609. // Use SYSTEM_ERROR to trigger endpoint switching
  610. vi.mocked(categorizeErrorAsync).mockResolvedValue(ErrorCategory.SYSTEM_ERROR);
  611. const doForward = vi.spyOn(
  612. ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown },
  613. "doForward"
  614. );
  615. const networkError = new TypeError("fetch failed");
  616. Object.assign(networkError, { code: "ECONNREFUSED" });
  617. // All attempts fail
  618. doForward.mockImplementation(async () => {
  619. throw networkError;
  620. });
  621. const sendPromise = ProxyForwarder.send(session);
  622. // Attach catch handler immediately to prevent unhandled rejection warnings
  623. let caughtError: Error | null = null;
  624. sendPromise.catch((e) => {
  625. caughtError = e;
  626. });
  627. await vi.runAllTimersAsync();
  628. expect(caughtError).not.toBeNull();
  629. expect(caughtError).toBeInstanceOf(ProxyError);
  630. // Should only call doForward twice (maxRetryAttempts=2), NOT 4 times
  631. expect(doForward).toHaveBeenCalledTimes(2);
  632. const chain = session.getProviderChain();
  633. // Only 2 attempts recorded
  634. expect(chain).toHaveLength(2);
  635. expect(chain[0].endpointId).toBe(1);
  636. expect(chain[1].endpointId).toBe(2);
  637. } finally {
  638. vi.useRealTimers();
  639. }
  640. });
  641. });
  642. describe("ProxyForwarder - endpoint stickiness on retry", () => {
  643. beforeEach(() => {
  644. vi.clearAllMocks();
  645. // Reset to default PROVIDER_ERROR behavior
  646. vi.mocked(categorizeErrorAsync).mockResolvedValue(ErrorCategory.PROVIDER_ERROR);
  647. });
  648. test("SYSTEM_ERROR: should switch to next endpoint on each network error retry", async () => {
  649. vi.useFakeTimers();
  650. try {
  651. const session = createSession();
  652. const provider = createProvider({
  653. providerType: "claude",
  654. providerVendorId: 123,
  655. maxRetryAttempts: 3,
  656. });
  657. session.setProvider(provider);
  658. // 3 endpoints sorted by latency
  659. mocks.getPreferredProviderEndpoints.mockResolvedValue([
  660. makeEndpoint({
  661. id: 1,
  662. vendorId: 123,
  663. providerType: "claude",
  664. url: "https://ep1.example.com",
  665. lastProbeLatencyMs: 100,
  666. }),
  667. makeEndpoint({
  668. id: 2,
  669. vendorId: 123,
  670. providerType: "claude",
  671. url: "https://ep2.example.com",
  672. lastProbeLatencyMs: 200,
  673. }),
  674. makeEndpoint({
  675. id: 3,
  676. vendorId: 123,
  677. providerType: "claude",
  678. url: "https://ep3.example.com",
  679. lastProbeLatencyMs: 300,
  680. }),
  681. ]);
  682. // Mock categorizeErrorAsync to return SYSTEM_ERROR (network error)
  683. vi.mocked(categorizeErrorAsync).mockResolvedValue(ErrorCategory.SYSTEM_ERROR);
  684. const doForward = vi.spyOn(
  685. ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown },
  686. "doForward"
  687. );
  688. // Create a network-like error (not ProxyError)
  689. const networkError = new TypeError("fetch failed");
  690. Object.assign(networkError, { code: "ECONNREFUSED" });
  691. // First two fail with network error, third succeeds
  692. doForward.mockImplementationOnce(async () => {
  693. throw networkError;
  694. });
  695. doForward.mockImplementationOnce(async () => {
  696. throw networkError;
  697. });
  698. doForward.mockResolvedValueOnce(
  699. new Response("{}", {
  700. status: 200,
  701. headers: { "content-type": "application/json", "content-length": "2" },
  702. })
  703. );
  704. const sendPromise = ProxyForwarder.send(session);
  705. await vi.advanceTimersByTimeAsync(300);
  706. const response = await sendPromise;
  707. expect(response.status).toBe(200);
  708. expect(doForward).toHaveBeenCalledTimes(3);
  709. const chain = session.getProviderChain();
  710. expect(chain).toHaveLength(3);
  711. // Network error should switch to next endpoint on each retry
  712. // attempt 1: endpoint 1, attempt 2: endpoint 2, attempt 3: endpoint 3
  713. expect(chain[0].endpointId).toBe(1);
  714. expect(chain[0].attemptNumber).toBe(1);
  715. expect(chain[1].endpointId).toBe(2);
  716. expect(chain[1].attemptNumber).toBe(2);
  717. expect(chain[2].endpointId).toBe(3);
  718. expect(chain[2].attemptNumber).toBe(3);
  719. } finally {
  720. vi.useRealTimers();
  721. }
  722. });
  723. test("PROVIDER_ERROR: should keep same endpoint on non-network error retry", async () => {
  724. vi.useFakeTimers();
  725. try {
  726. const session = createSession();
  727. const provider = createProvider({
  728. providerType: "claude",
  729. providerVendorId: 123,
  730. maxRetryAttempts: 3,
  731. });
  732. session.setProvider(provider);
  733. // 3 endpoints sorted by latency
  734. mocks.getPreferredProviderEndpoints.mockResolvedValue([
  735. makeEndpoint({
  736. id: 1,
  737. vendorId: 123,
  738. providerType: "claude",
  739. url: "https://ep1.example.com",
  740. lastProbeLatencyMs: 100,
  741. }),
  742. makeEndpoint({
  743. id: 2,
  744. vendorId: 123,
  745. providerType: "claude",
  746. url: "https://ep2.example.com",
  747. lastProbeLatencyMs: 200,
  748. }),
  749. makeEndpoint({
  750. id: 3,
  751. vendorId: 123,
  752. providerType: "claude",
  753. url: "https://ep3.example.com",
  754. lastProbeLatencyMs: 300,
  755. }),
  756. ]);
  757. // Mock categorizeErrorAsync to return PROVIDER_ERROR (HTTP error, not network)
  758. vi.mocked(categorizeErrorAsync).mockResolvedValue(ErrorCategory.PROVIDER_ERROR);
  759. const doForward = vi.spyOn(
  760. ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown },
  761. "doForward"
  762. );
  763. // First two fail with HTTP 500, third succeeds
  764. doForward.mockImplementationOnce(async () => {
  765. throw new ProxyError("server error", 500);
  766. });
  767. doForward.mockImplementationOnce(async () => {
  768. throw new ProxyError("server error", 500);
  769. });
  770. doForward.mockResolvedValueOnce(
  771. new Response("{}", {
  772. status: 200,
  773. headers: { "content-type": "application/json", "content-length": "2" },
  774. })
  775. );
  776. const sendPromise = ProxyForwarder.send(session);
  777. await vi.advanceTimersByTimeAsync(300);
  778. const response = await sendPromise;
  779. expect(response.status).toBe(200);
  780. expect(doForward).toHaveBeenCalledTimes(3);
  781. const chain = session.getProviderChain();
  782. expect(chain).toHaveLength(3);
  783. // Non-network error should keep same endpoint on all retries
  784. // All 3 attempts should use endpoint 1 (sticky)
  785. expect(chain[0].endpointId).toBe(1);
  786. expect(chain[0].attemptNumber).toBe(1);
  787. expect(chain[1].endpointId).toBe(1);
  788. expect(chain[1].attemptNumber).toBe(2);
  789. expect(chain[2].endpointId).toBe(1);
  790. expect(chain[2].attemptNumber).toBe(3);
  791. } finally {
  792. vi.useRealTimers();
  793. }
  794. });
  795. test("SYSTEM_ERROR: should not wrap around when endpoints exhausted", async () => {
  796. vi.useFakeTimers();
  797. try {
  798. const session = createSession();
  799. const provider = createProvider({
  800. providerType: "claude",
  801. providerVendorId: 123,
  802. maxRetryAttempts: 4, // More retries than endpoints
  803. });
  804. session.setProvider(provider);
  805. // Only 2 endpoints available
  806. mocks.getPreferredProviderEndpoints.mockResolvedValue([
  807. makeEndpoint({
  808. id: 1,
  809. vendorId: 123,
  810. providerType: "claude",
  811. url: "https://ep1.example.com",
  812. lastProbeLatencyMs: 100,
  813. }),
  814. makeEndpoint({
  815. id: 2,
  816. vendorId: 123,
  817. providerType: "claude",
  818. url: "https://ep2.example.com",
  819. lastProbeLatencyMs: 200,
  820. }),
  821. ]);
  822. // Mock categorizeErrorAsync to return SYSTEM_ERROR (network error)
  823. vi.mocked(categorizeErrorAsync).mockResolvedValue(ErrorCategory.SYSTEM_ERROR);
  824. const doForward = vi.spyOn(
  825. ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown },
  826. "doForward"
  827. );
  828. // Create a network-like error
  829. const networkError = new TypeError("fetch failed");
  830. Object.assign(networkError, { code: "ETIMEDOUT" });
  831. // All 4 attempts fail with network error, then mock switches provider
  832. doForward.mockImplementation(async () => {
  833. throw networkError;
  834. });
  835. const sendPromise = ProxyForwarder.send(session);
  836. // Attach catch handler immediately to prevent unhandled rejection warnings
  837. let caughtError: Error | null = null;
  838. sendPromise.catch((e) => {
  839. caughtError = e;
  840. });
  841. await vi.runAllTimersAsync();
  842. // Should fail eventually (no successful response)
  843. expect(caughtError).not.toBeNull();
  844. expect(caughtError).toBeInstanceOf(ProxyError);
  845. const chain = session.getProviderChain();
  846. // Should have attempted with both endpoints, but NOT wrap around
  847. // Pattern should be: endpoint 1, endpoint 2, endpoint 2, endpoint 2 (stay at last)
  848. // NOT: endpoint 1, endpoint 2, endpoint 1, endpoint 2 (wrap around)
  849. expect(chain.length).toBeGreaterThanOrEqual(2);
  850. // First attempt uses endpoint 1
  851. expect(chain[0].endpointId).toBe(1);
  852. // Second attempt uses endpoint 2
  853. expect(chain[1].endpointId).toBe(2);
  854. // Subsequent attempts should stay at endpoint 2 (no wrap-around)
  855. if (chain.length > 2) {
  856. expect(chain[2].endpointId).toBe(2);
  857. }
  858. if (chain.length > 3) {
  859. expect(chain[3].endpointId).toBe(2);
  860. }
  861. } finally {
  862. vi.useRealTimers();
  863. }
  864. });
  865. test("mixed errors: PROVIDER_ERROR should not advance endpoint index", async () => {
  866. vi.useFakeTimers();
  867. try {
  868. const session = createSession();
  869. const provider = createProvider({
  870. providerType: "claude",
  871. providerVendorId: 123,
  872. maxRetryAttempts: 4,
  873. });
  874. session.setProvider(provider);
  875. // 3 endpoints
  876. mocks.getPreferredProviderEndpoints.mockResolvedValue([
  877. makeEndpoint({
  878. id: 1,
  879. vendorId: 123,
  880. providerType: "claude",
  881. url: "https://ep1.example.com",
  882. lastProbeLatencyMs: 100,
  883. }),
  884. makeEndpoint({
  885. id: 2,
  886. vendorId: 123,
  887. providerType: "claude",
  888. url: "https://ep2.example.com",
  889. lastProbeLatencyMs: 200,
  890. }),
  891. makeEndpoint({
  892. id: 3,
  893. vendorId: 123,
  894. providerType: "claude",
  895. url: "https://ep3.example.com",
  896. lastProbeLatencyMs: 300,
  897. }),
  898. ]);
  899. const doForward = vi.spyOn(
  900. ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown },
  901. "doForward"
  902. );
  903. // Create errors
  904. const networkError = new TypeError("fetch failed");
  905. Object.assign(networkError, { code: "ECONNREFUSED" });
  906. const httpError = new ProxyError("server error", 500);
  907. // Attempt 1: SYSTEM_ERROR (switch endpoint)
  908. // Attempt 2: PROVIDER_ERROR (keep endpoint)
  909. // Attempt 3: SYSTEM_ERROR (switch endpoint)
  910. // Attempt 4: success
  911. let attemptCount = 0;
  912. vi.mocked(categorizeErrorAsync).mockImplementation(async () => {
  913. attemptCount++;
  914. if (attemptCount === 1) return ErrorCategory.SYSTEM_ERROR;
  915. if (attemptCount === 2) return ErrorCategory.PROVIDER_ERROR;
  916. if (attemptCount === 3) return ErrorCategory.SYSTEM_ERROR;
  917. return ErrorCategory.PROVIDER_ERROR;
  918. });
  919. doForward.mockImplementationOnce(async () => {
  920. throw networkError; // SYSTEM_ERROR -> advance to ep2
  921. });
  922. doForward.mockImplementationOnce(async () => {
  923. throw httpError; // PROVIDER_ERROR -> stay at ep2
  924. });
  925. doForward.mockImplementationOnce(async () => {
  926. throw networkError; // SYSTEM_ERROR -> advance to ep3
  927. });
  928. doForward.mockResolvedValueOnce(
  929. new Response("{}", {
  930. status: 200,
  931. headers: { "content-type": "application/json", "content-length": "2" },
  932. })
  933. );
  934. const sendPromise = ProxyForwarder.send(session);
  935. await vi.advanceTimersByTimeAsync(500);
  936. const response = await sendPromise;
  937. expect(response.status).toBe(200);
  938. expect(doForward).toHaveBeenCalledTimes(4);
  939. const chain = session.getProviderChain();
  940. expect(chain).toHaveLength(4);
  941. // Verify endpoint progression:
  942. // attempt 1: ep1 (SYSTEM_ERROR -> advance)
  943. // attempt 2: ep2 (PROVIDER_ERROR -> stay)
  944. // attempt 3: ep2 (SYSTEM_ERROR -> advance)
  945. // attempt 4: ep3 (success)
  946. expect(chain[0].endpointId).toBe(1);
  947. expect(chain[1].endpointId).toBe(2);
  948. expect(chain[2].endpointId).toBe(2);
  949. expect(chain[3].endpointId).toBe(3);
  950. } finally {
  951. vi.useRealTimers();
  952. }
  953. });
  954. });