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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017
  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. joinClaudePool: false,
  108. codexInstructionsStrategy: "auto",
  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. cacheTtlResolved: null,
  180. context1mApplied: false,
  181. specialSettings: [],
  182. cachedPriceData: undefined,
  183. cachedBillingModelSource: undefined,
  184. providersSnapshot: [],
  185. isHeaderModified: () => false,
  186. });
  187. return session as ProxySession;
  188. }
  189. describe("ProxyForwarder - retry limit enforcement", () => {
  190. beforeEach(() => {
  191. vi.clearAllMocks();
  192. });
  193. test("endpoints > maxRetry: should only use top N lowest-latency endpoints", async () => {
  194. vi.useFakeTimers();
  195. try {
  196. const session = createSession();
  197. // Configure provider with maxRetryAttempts=2 but 4 endpoints available
  198. const provider = createProvider({
  199. providerType: "claude",
  200. providerVendorId: 123,
  201. maxRetryAttempts: 2,
  202. });
  203. session.setProvider(provider);
  204. // Return 4 endpoints sorted by latency (lowest first)
  205. mocks.getPreferredProviderEndpoints.mockResolvedValue([
  206. makeEndpoint({
  207. id: 1,
  208. vendorId: 123,
  209. providerType: "claude",
  210. url: "https://ep1.example.com",
  211. lastProbeLatencyMs: 100,
  212. }),
  213. makeEndpoint({
  214. id: 2,
  215. vendorId: 123,
  216. providerType: "claude",
  217. url: "https://ep2.example.com",
  218. lastProbeLatencyMs: 200,
  219. }),
  220. makeEndpoint({
  221. id: 3,
  222. vendorId: 123,
  223. providerType: "claude",
  224. url: "https://ep3.example.com",
  225. lastProbeLatencyMs: 300,
  226. }),
  227. makeEndpoint({
  228. id: 4,
  229. vendorId: 123,
  230. providerType: "claude",
  231. url: "https://ep4.example.com",
  232. lastProbeLatencyMs: 400,
  233. }),
  234. ]);
  235. // Use SYSTEM_ERROR to trigger endpoint switching on retry
  236. vi.mocked(categorizeErrorAsync).mockResolvedValue(ErrorCategory.SYSTEM_ERROR);
  237. const doForward = vi.spyOn(
  238. ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown },
  239. "doForward"
  240. );
  241. // Create a network-like error
  242. const networkError = new TypeError("fetch failed");
  243. Object.assign(networkError, { code: "ECONNREFUSED" });
  244. // First attempt fails with network error, second succeeds
  245. doForward.mockImplementationOnce(async () => {
  246. throw networkError;
  247. });
  248. doForward.mockResolvedValueOnce(
  249. new Response("{}", {
  250. status: 200,
  251. headers: { "content-type": "application/json", "content-length": "2" },
  252. })
  253. );
  254. const sendPromise = ProxyForwarder.send(session);
  255. await vi.advanceTimersByTimeAsync(100);
  256. const response = await sendPromise;
  257. expect(response.status).toBe(200);
  258. // Should only call doForward twice (maxRetryAttempts=2)
  259. expect(doForward).toHaveBeenCalledTimes(2);
  260. const chain = session.getProviderChain();
  261. expect(chain).toHaveLength(2);
  262. // First attempt should use endpoint 1 (lowest latency)
  263. expect(chain[0].endpointId).toBe(1);
  264. expect(chain[0].attemptNumber).toBe(1);
  265. // Second attempt should use endpoint 2 (SYSTEM_ERROR advances endpoint)
  266. expect(chain[1].endpointId).toBe(2);
  267. expect(chain[1].attemptNumber).toBe(2);
  268. // Endpoints 3 and 4 should NOT be used
  269. } finally {
  270. vi.useRealTimers();
  271. }
  272. });
  273. test("endpoints < maxRetry: should stay at last endpoint after exhausting all (no wrap-around)", async () => {
  274. vi.useFakeTimers();
  275. try {
  276. const session = createSession();
  277. // Configure provider with maxRetryAttempts=5 but only 2 endpoints
  278. const provider = createProvider({
  279. providerType: "claude",
  280. providerVendorId: 123,
  281. maxRetryAttempts: 5,
  282. });
  283. session.setProvider(provider);
  284. mocks.getPreferredProviderEndpoints.mockResolvedValue([
  285. makeEndpoint({
  286. id: 1,
  287. vendorId: 123,
  288. providerType: "claude",
  289. url: "https://ep1.example.com",
  290. lastProbeLatencyMs: 100,
  291. }),
  292. makeEndpoint({
  293. id: 2,
  294. vendorId: 123,
  295. providerType: "claude",
  296. url: "https://ep2.example.com",
  297. lastProbeLatencyMs: 200,
  298. }),
  299. ]);
  300. // Use SYSTEM_ERROR to trigger endpoint switching
  301. vi.mocked(categorizeErrorAsync).mockResolvedValue(ErrorCategory.SYSTEM_ERROR);
  302. const doForward = vi.spyOn(
  303. ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown },
  304. "doForward"
  305. );
  306. const networkError = new TypeError("fetch failed");
  307. Object.assign(networkError, { code: "ECONNREFUSED" });
  308. // All attempts fail except the last one
  309. doForward.mockImplementation(async () => {
  310. throw networkError;
  311. });
  312. // 5th attempt succeeds
  313. doForward.mockImplementationOnce(async () => {
  314. throw networkError;
  315. });
  316. doForward.mockImplementationOnce(async () => {
  317. throw networkError;
  318. });
  319. doForward.mockImplementationOnce(async () => {
  320. throw networkError;
  321. });
  322. doForward.mockImplementationOnce(async () => {
  323. throw networkError;
  324. });
  325. doForward.mockResolvedValueOnce(
  326. new Response("{}", {
  327. status: 200,
  328. headers: { "content-type": "application/json", "content-length": "2" },
  329. })
  330. );
  331. const sendPromise = ProxyForwarder.send(session);
  332. await vi.advanceTimersByTimeAsync(500);
  333. const response = await sendPromise;
  334. expect(response.status).toBe(200);
  335. // Should call doForward 5 times (maxRetryAttempts=5)
  336. expect(doForward).toHaveBeenCalledTimes(5);
  337. const chain = session.getProviderChain();
  338. expect(chain).toHaveLength(5);
  339. // Verify NO wrap-around pattern: 1, 2, 2, 2, 2 (stays at last endpoint)
  340. expect(chain[0].endpointId).toBe(1);
  341. expect(chain[1].endpointId).toBe(2);
  342. expect(chain[2].endpointId).toBe(2); // stays at endpoint 2
  343. expect(chain[3].endpointId).toBe(2);
  344. expect(chain[4].endpointId).toBe(2);
  345. } finally {
  346. vi.useRealTimers();
  347. }
  348. });
  349. test("endpoints = maxRetry: each endpoint should be tried exactly once with SYSTEM_ERROR", async () => {
  350. vi.useFakeTimers();
  351. try {
  352. const session = createSession();
  353. // Configure provider with maxRetryAttempts=3 and 3 endpoints
  354. const provider = createProvider({
  355. providerType: "claude",
  356. providerVendorId: 123,
  357. maxRetryAttempts: 3,
  358. });
  359. session.setProvider(provider);
  360. mocks.getPreferredProviderEndpoints.mockResolvedValue([
  361. makeEndpoint({
  362. id: 1,
  363. vendorId: 123,
  364. providerType: "claude",
  365. url: "https://ep1.example.com",
  366. lastProbeLatencyMs: 100,
  367. }),
  368. makeEndpoint({
  369. id: 2,
  370. vendorId: 123,
  371. providerType: "claude",
  372. url: "https://ep2.example.com",
  373. lastProbeLatencyMs: 200,
  374. }),
  375. makeEndpoint({
  376. id: 3,
  377. vendorId: 123,
  378. providerType: "claude",
  379. url: "https://ep3.example.com",
  380. lastProbeLatencyMs: 300,
  381. }),
  382. ]);
  383. // Use SYSTEM_ERROR to trigger endpoint switching
  384. vi.mocked(categorizeErrorAsync).mockResolvedValue(ErrorCategory.SYSTEM_ERROR);
  385. const doForward = vi.spyOn(
  386. ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown },
  387. "doForward"
  388. );
  389. const networkError = new TypeError("fetch failed");
  390. Object.assign(networkError, { code: "ECONNREFUSED" });
  391. // First two fail with network error, third succeeds
  392. doForward.mockImplementationOnce(async () => {
  393. throw networkError;
  394. });
  395. doForward.mockImplementationOnce(async () => {
  396. throw networkError;
  397. });
  398. doForward.mockResolvedValueOnce(
  399. new Response("{}", {
  400. status: 200,
  401. headers: { "content-type": "application/json", "content-length": "2" },
  402. })
  403. );
  404. const sendPromise = ProxyForwarder.send(session);
  405. await vi.advanceTimersByTimeAsync(300);
  406. const response = await sendPromise;
  407. expect(response.status).toBe(200);
  408. expect(doForward).toHaveBeenCalledTimes(3);
  409. const chain = session.getProviderChain();
  410. expect(chain).toHaveLength(3);
  411. // Each endpoint tried exactly once (SYSTEM_ERROR advances endpoint)
  412. expect(chain[0].endpointId).toBe(1);
  413. expect(chain[1].endpointId).toBe(2);
  414. expect(chain[2].endpointId).toBe(3);
  415. } finally {
  416. vi.useRealTimers();
  417. }
  418. });
  419. test("MCP request: should use provider.url only, ignore vendor endpoints", async () => {
  420. const session = createSession(new URL("https://example.com/mcp/custom-endpoint"));
  421. const provider = createProvider({
  422. providerType: "claude",
  423. providerVendorId: 123,
  424. maxRetryAttempts: 2,
  425. url: "https://provider.example.com/mcp",
  426. });
  427. session.setProvider(provider);
  428. // Even if endpoints are available, MCP should not use them
  429. mocks.getPreferredProviderEndpoints.mockResolvedValue([
  430. makeEndpoint({
  431. id: 1,
  432. vendorId: 123,
  433. providerType: "claude",
  434. url: "https://ep1.example.com",
  435. }),
  436. makeEndpoint({
  437. id: 2,
  438. vendorId: 123,
  439. providerType: "claude",
  440. url: "https://ep2.example.com",
  441. }),
  442. ]);
  443. const doForward = vi.spyOn(
  444. ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown },
  445. "doForward"
  446. );
  447. doForward.mockResolvedValueOnce(
  448. new Response("{}", {
  449. status: 200,
  450. headers: { "content-type": "application/json", "content-length": "2" },
  451. })
  452. );
  453. const response = await ProxyForwarder.send(session);
  454. expect(response.status).toBe(200);
  455. // getPreferredProviderEndpoints should NOT be called for MCP requests
  456. expect(mocks.getPreferredProviderEndpoints).not.toHaveBeenCalled();
  457. const chain = session.getProviderChain();
  458. expect(chain).toHaveLength(1);
  459. // endpointId should be null (using provider.url)
  460. expect(chain[0].endpointId).toBeNull();
  461. });
  462. test("no vendor endpoints: should use provider.url with configured maxRetry", async () => {
  463. vi.useFakeTimers();
  464. try {
  465. const session = createSession();
  466. // Provider without vendorId
  467. const provider = createProvider({
  468. providerType: "claude",
  469. providerVendorId: null as unknown as number,
  470. maxRetryAttempts: 3,
  471. url: "https://provider.example.com",
  472. });
  473. session.setProvider(provider);
  474. const doForward = vi.spyOn(
  475. ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown },
  476. "doForward"
  477. );
  478. // First two fail, third succeeds
  479. doForward.mockImplementationOnce(async () => {
  480. throw new ProxyError("failed", 500);
  481. });
  482. doForward.mockImplementationOnce(async () => {
  483. throw new ProxyError("failed", 500);
  484. });
  485. doForward.mockResolvedValueOnce(
  486. new Response("{}", {
  487. status: 200,
  488. headers: { "content-type": "application/json", "content-length": "2" },
  489. })
  490. );
  491. const sendPromise = ProxyForwarder.send(session);
  492. await vi.advanceTimersByTimeAsync(300);
  493. const response = await sendPromise;
  494. expect(response.status).toBe(200);
  495. // Should retry up to maxRetryAttempts times
  496. expect(doForward).toHaveBeenCalledTimes(3);
  497. // getPreferredProviderEndpoints should NOT be called (no vendorId)
  498. expect(mocks.getPreferredProviderEndpoints).not.toHaveBeenCalled();
  499. const chain = session.getProviderChain();
  500. expect(chain).toHaveLength(3);
  501. // All attempts should use provider.url (endpointId=null)
  502. expect(chain[0].endpointId).toBeNull();
  503. expect(chain[1].endpointId).toBeNull();
  504. expect(chain[2].endpointId).toBeNull();
  505. } finally {
  506. vi.useRealTimers();
  507. }
  508. });
  509. test("all retries exhausted: should not exceed maxRetryAttempts", async () => {
  510. vi.useFakeTimers();
  511. try {
  512. const session = createSession();
  513. const provider = createProvider({
  514. providerType: "claude",
  515. providerVendorId: 123,
  516. maxRetryAttempts: 2,
  517. });
  518. session.setProvider(provider);
  519. // 4 endpoints available but maxRetry=2
  520. mocks.getPreferredProviderEndpoints.mockResolvedValue([
  521. makeEndpoint({
  522. id: 1,
  523. vendorId: 123,
  524. providerType: "claude",
  525. url: "https://ep1.example.com",
  526. lastProbeLatencyMs: 100,
  527. }),
  528. makeEndpoint({
  529. id: 2,
  530. vendorId: 123,
  531. providerType: "claude",
  532. url: "https://ep2.example.com",
  533. lastProbeLatencyMs: 200,
  534. }),
  535. makeEndpoint({
  536. id: 3,
  537. vendorId: 123,
  538. providerType: "claude",
  539. url: "https://ep3.example.com",
  540. lastProbeLatencyMs: 300,
  541. }),
  542. makeEndpoint({
  543. id: 4,
  544. vendorId: 123,
  545. providerType: "claude",
  546. url: "https://ep4.example.com",
  547. lastProbeLatencyMs: 400,
  548. }),
  549. ]);
  550. // Use SYSTEM_ERROR to trigger endpoint switching
  551. vi.mocked(categorizeErrorAsync).mockResolvedValue(ErrorCategory.SYSTEM_ERROR);
  552. const doForward = vi.spyOn(
  553. ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown },
  554. "doForward"
  555. );
  556. const networkError = new TypeError("fetch failed");
  557. Object.assign(networkError, { code: "ECONNREFUSED" });
  558. // All attempts fail
  559. doForward.mockImplementation(async () => {
  560. throw networkError;
  561. });
  562. const sendPromise = ProxyForwarder.send(session);
  563. // Attach catch handler immediately to prevent unhandled rejection warnings
  564. let caughtError: Error | null = null;
  565. sendPromise.catch((e) => {
  566. caughtError = e;
  567. });
  568. await vi.runAllTimersAsync();
  569. expect(caughtError).not.toBeNull();
  570. expect(caughtError).toBeInstanceOf(ProxyError);
  571. // Should only call doForward twice (maxRetryAttempts=2), NOT 4 times
  572. expect(doForward).toHaveBeenCalledTimes(2);
  573. const chain = session.getProviderChain();
  574. // Only 2 attempts recorded
  575. expect(chain).toHaveLength(2);
  576. expect(chain[0].endpointId).toBe(1);
  577. expect(chain[1].endpointId).toBe(2);
  578. } finally {
  579. vi.useRealTimers();
  580. }
  581. });
  582. });
  583. describe("ProxyForwarder - endpoint stickiness on retry", () => {
  584. beforeEach(() => {
  585. vi.clearAllMocks();
  586. // Reset to default PROVIDER_ERROR behavior
  587. vi.mocked(categorizeErrorAsync).mockResolvedValue(ErrorCategory.PROVIDER_ERROR);
  588. });
  589. test("SYSTEM_ERROR: should switch to next endpoint on each network error retry", async () => {
  590. vi.useFakeTimers();
  591. try {
  592. const session = createSession();
  593. const provider = createProvider({
  594. providerType: "claude",
  595. providerVendorId: 123,
  596. maxRetryAttempts: 3,
  597. });
  598. session.setProvider(provider);
  599. // 3 endpoints sorted by latency
  600. mocks.getPreferredProviderEndpoints.mockResolvedValue([
  601. makeEndpoint({
  602. id: 1,
  603. vendorId: 123,
  604. providerType: "claude",
  605. url: "https://ep1.example.com",
  606. lastProbeLatencyMs: 100,
  607. }),
  608. makeEndpoint({
  609. id: 2,
  610. vendorId: 123,
  611. providerType: "claude",
  612. url: "https://ep2.example.com",
  613. lastProbeLatencyMs: 200,
  614. }),
  615. makeEndpoint({
  616. id: 3,
  617. vendorId: 123,
  618. providerType: "claude",
  619. url: "https://ep3.example.com",
  620. lastProbeLatencyMs: 300,
  621. }),
  622. ]);
  623. // Mock categorizeErrorAsync to return SYSTEM_ERROR (network error)
  624. vi.mocked(categorizeErrorAsync).mockResolvedValue(ErrorCategory.SYSTEM_ERROR);
  625. const doForward = vi.spyOn(
  626. ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown },
  627. "doForward"
  628. );
  629. // Create a network-like error (not ProxyError)
  630. const networkError = new TypeError("fetch failed");
  631. Object.assign(networkError, { code: "ECONNREFUSED" });
  632. // First two fail with network error, third succeeds
  633. doForward.mockImplementationOnce(async () => {
  634. throw networkError;
  635. });
  636. doForward.mockImplementationOnce(async () => {
  637. throw networkError;
  638. });
  639. doForward.mockResolvedValueOnce(
  640. new Response("{}", {
  641. status: 200,
  642. headers: { "content-type": "application/json", "content-length": "2" },
  643. })
  644. );
  645. const sendPromise = ProxyForwarder.send(session);
  646. await vi.advanceTimersByTimeAsync(300);
  647. const response = await sendPromise;
  648. expect(response.status).toBe(200);
  649. expect(doForward).toHaveBeenCalledTimes(3);
  650. const chain = session.getProviderChain();
  651. expect(chain).toHaveLength(3);
  652. // Network error should switch to next endpoint on each retry
  653. // attempt 1: endpoint 1, attempt 2: endpoint 2, attempt 3: endpoint 3
  654. expect(chain[0].endpointId).toBe(1);
  655. expect(chain[0].attemptNumber).toBe(1);
  656. expect(chain[1].endpointId).toBe(2);
  657. expect(chain[1].attemptNumber).toBe(2);
  658. expect(chain[2].endpointId).toBe(3);
  659. expect(chain[2].attemptNumber).toBe(3);
  660. } finally {
  661. vi.useRealTimers();
  662. }
  663. });
  664. test("PROVIDER_ERROR: should keep same endpoint on non-network error retry", async () => {
  665. vi.useFakeTimers();
  666. try {
  667. const session = createSession();
  668. const provider = createProvider({
  669. providerType: "claude",
  670. providerVendorId: 123,
  671. maxRetryAttempts: 3,
  672. });
  673. session.setProvider(provider);
  674. // 3 endpoints sorted by latency
  675. mocks.getPreferredProviderEndpoints.mockResolvedValue([
  676. makeEndpoint({
  677. id: 1,
  678. vendorId: 123,
  679. providerType: "claude",
  680. url: "https://ep1.example.com",
  681. lastProbeLatencyMs: 100,
  682. }),
  683. makeEndpoint({
  684. id: 2,
  685. vendorId: 123,
  686. providerType: "claude",
  687. url: "https://ep2.example.com",
  688. lastProbeLatencyMs: 200,
  689. }),
  690. makeEndpoint({
  691. id: 3,
  692. vendorId: 123,
  693. providerType: "claude",
  694. url: "https://ep3.example.com",
  695. lastProbeLatencyMs: 300,
  696. }),
  697. ]);
  698. // Mock categorizeErrorAsync to return PROVIDER_ERROR (HTTP error, not network)
  699. vi.mocked(categorizeErrorAsync).mockResolvedValue(ErrorCategory.PROVIDER_ERROR);
  700. const doForward = vi.spyOn(
  701. ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown },
  702. "doForward"
  703. );
  704. // First two fail with HTTP 500, third succeeds
  705. doForward.mockImplementationOnce(async () => {
  706. throw new ProxyError("server error", 500);
  707. });
  708. doForward.mockImplementationOnce(async () => {
  709. throw new ProxyError("server error", 500);
  710. });
  711. doForward.mockResolvedValueOnce(
  712. new Response("{}", {
  713. status: 200,
  714. headers: { "content-type": "application/json", "content-length": "2" },
  715. })
  716. );
  717. const sendPromise = ProxyForwarder.send(session);
  718. await vi.advanceTimersByTimeAsync(300);
  719. const response = await sendPromise;
  720. expect(response.status).toBe(200);
  721. expect(doForward).toHaveBeenCalledTimes(3);
  722. const chain = session.getProviderChain();
  723. expect(chain).toHaveLength(3);
  724. // Non-network error should keep same endpoint on all retries
  725. // All 3 attempts should use endpoint 1 (sticky)
  726. expect(chain[0].endpointId).toBe(1);
  727. expect(chain[0].attemptNumber).toBe(1);
  728. expect(chain[1].endpointId).toBe(1);
  729. expect(chain[1].attemptNumber).toBe(2);
  730. expect(chain[2].endpointId).toBe(1);
  731. expect(chain[2].attemptNumber).toBe(3);
  732. } finally {
  733. vi.useRealTimers();
  734. }
  735. });
  736. test("SYSTEM_ERROR: should not wrap around when endpoints exhausted", async () => {
  737. vi.useFakeTimers();
  738. try {
  739. const session = createSession();
  740. const provider = createProvider({
  741. providerType: "claude",
  742. providerVendorId: 123,
  743. maxRetryAttempts: 4, // More retries than endpoints
  744. });
  745. session.setProvider(provider);
  746. // Only 2 endpoints available
  747. mocks.getPreferredProviderEndpoints.mockResolvedValue([
  748. makeEndpoint({
  749. id: 1,
  750. vendorId: 123,
  751. providerType: "claude",
  752. url: "https://ep1.example.com",
  753. lastProbeLatencyMs: 100,
  754. }),
  755. makeEndpoint({
  756. id: 2,
  757. vendorId: 123,
  758. providerType: "claude",
  759. url: "https://ep2.example.com",
  760. lastProbeLatencyMs: 200,
  761. }),
  762. ]);
  763. // Mock categorizeErrorAsync to return SYSTEM_ERROR (network error)
  764. vi.mocked(categorizeErrorAsync).mockResolvedValue(ErrorCategory.SYSTEM_ERROR);
  765. const doForward = vi.spyOn(
  766. ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown },
  767. "doForward"
  768. );
  769. // Create a network-like error
  770. const networkError = new TypeError("fetch failed");
  771. Object.assign(networkError, { code: "ETIMEDOUT" });
  772. // All 4 attempts fail with network error, then mock switches provider
  773. doForward.mockImplementation(async () => {
  774. throw networkError;
  775. });
  776. const sendPromise = ProxyForwarder.send(session);
  777. // Attach catch handler immediately to prevent unhandled rejection warnings
  778. let caughtError: Error | null = null;
  779. sendPromise.catch((e) => {
  780. caughtError = e;
  781. });
  782. await vi.runAllTimersAsync();
  783. // Should fail eventually (no successful response)
  784. expect(caughtError).not.toBeNull();
  785. expect(caughtError).toBeInstanceOf(ProxyError);
  786. const chain = session.getProviderChain();
  787. // Should have attempted with both endpoints, but NOT wrap around
  788. // Pattern should be: endpoint 1, endpoint 2, endpoint 2, endpoint 2 (stay at last)
  789. // NOT: endpoint 1, endpoint 2, endpoint 1, endpoint 2 (wrap around)
  790. expect(chain.length).toBeGreaterThanOrEqual(2);
  791. // First attempt uses endpoint 1
  792. expect(chain[0].endpointId).toBe(1);
  793. // Second attempt uses endpoint 2
  794. expect(chain[1].endpointId).toBe(2);
  795. // Subsequent attempts should stay at endpoint 2 (no wrap-around)
  796. if (chain.length > 2) {
  797. expect(chain[2].endpointId).toBe(2);
  798. }
  799. if (chain.length > 3) {
  800. expect(chain[3].endpointId).toBe(2);
  801. }
  802. } finally {
  803. vi.useRealTimers();
  804. }
  805. });
  806. test("mixed errors: PROVIDER_ERROR should not advance endpoint index", async () => {
  807. vi.useFakeTimers();
  808. try {
  809. const session = createSession();
  810. const provider = createProvider({
  811. providerType: "claude",
  812. providerVendorId: 123,
  813. maxRetryAttempts: 4,
  814. });
  815. session.setProvider(provider);
  816. // 3 endpoints
  817. mocks.getPreferredProviderEndpoints.mockResolvedValue([
  818. makeEndpoint({
  819. id: 1,
  820. vendorId: 123,
  821. providerType: "claude",
  822. url: "https://ep1.example.com",
  823. lastProbeLatencyMs: 100,
  824. }),
  825. makeEndpoint({
  826. id: 2,
  827. vendorId: 123,
  828. providerType: "claude",
  829. url: "https://ep2.example.com",
  830. lastProbeLatencyMs: 200,
  831. }),
  832. makeEndpoint({
  833. id: 3,
  834. vendorId: 123,
  835. providerType: "claude",
  836. url: "https://ep3.example.com",
  837. lastProbeLatencyMs: 300,
  838. }),
  839. ]);
  840. const doForward = vi.spyOn(
  841. ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown },
  842. "doForward"
  843. );
  844. // Create errors
  845. const networkError = new TypeError("fetch failed");
  846. Object.assign(networkError, { code: "ECONNREFUSED" });
  847. const httpError = new ProxyError("server error", 500);
  848. // Attempt 1: SYSTEM_ERROR (switch endpoint)
  849. // Attempt 2: PROVIDER_ERROR (keep endpoint)
  850. // Attempt 3: SYSTEM_ERROR (switch endpoint)
  851. // Attempt 4: success
  852. let attemptCount = 0;
  853. vi.mocked(categorizeErrorAsync).mockImplementation(async () => {
  854. attemptCount++;
  855. if (attemptCount === 1) return ErrorCategory.SYSTEM_ERROR;
  856. if (attemptCount === 2) return ErrorCategory.PROVIDER_ERROR;
  857. if (attemptCount === 3) return ErrorCategory.SYSTEM_ERROR;
  858. return ErrorCategory.PROVIDER_ERROR;
  859. });
  860. doForward.mockImplementationOnce(async () => {
  861. throw networkError; // SYSTEM_ERROR -> advance to ep2
  862. });
  863. doForward.mockImplementationOnce(async () => {
  864. throw httpError; // PROVIDER_ERROR -> stay at ep2
  865. });
  866. doForward.mockImplementationOnce(async () => {
  867. throw networkError; // SYSTEM_ERROR -> advance to ep3
  868. });
  869. doForward.mockResolvedValueOnce(
  870. new Response("{}", {
  871. status: 200,
  872. headers: { "content-type": "application/json", "content-length": "2" },
  873. })
  874. );
  875. const sendPromise = ProxyForwarder.send(session);
  876. await vi.advanceTimersByTimeAsync(500);
  877. const response = await sendPromise;
  878. expect(response.status).toBe(200);
  879. expect(doForward).toHaveBeenCalledTimes(4);
  880. const chain = session.getProviderChain();
  881. expect(chain).toHaveLength(4);
  882. // Verify endpoint progression:
  883. // attempt 1: ep1 (SYSTEM_ERROR -> advance)
  884. // attempt 2: ep2 (PROVIDER_ERROR -> stay)
  885. // attempt 3: ep2 (SYSTEM_ERROR -> advance)
  886. // attempt 4: ep3 (success)
  887. expect(chain[0].endpointId).toBe(1);
  888. expect(chain[1].endpointId).toBe(2);
  889. expect(chain[2].endpointId).toBe(2);
  890. expect(chain[3].endpointId).toBe(3);
  891. } finally {
  892. vi.useRealTimers();
  893. }
  894. });
  895. });