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

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