2
0

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

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390
  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. getErrorDetectionResultAsync: vi.fn(async () => ({ matched: false })),
  61. };
  62. });
  63. import { ProxyForwarder } from "@/app/v1/_lib/proxy/forwarder";
  64. import {
  65. ProxyError,
  66. ErrorCategory,
  67. categorizeErrorAsync,
  68. getErrorDetectionResultAsync,
  69. } from "@/app/v1/_lib/proxy/errors";
  70. import { ProxySession } from "@/app/v1/_lib/proxy/session";
  71. import { logger } from "@/lib/logger";
  72. import type { Provider, ProviderEndpoint, ProviderType } from "@/types/provider";
  73. function makeEndpoint(input: {
  74. id: number;
  75. vendorId: number;
  76. providerType: ProviderType;
  77. url: string;
  78. lastProbeLatencyMs?: number | null;
  79. }): ProviderEndpoint {
  80. const now = new Date("2026-01-01T00:00:00.000Z");
  81. return {
  82. id: input.id,
  83. vendorId: input.vendorId,
  84. providerType: input.providerType,
  85. url: input.url,
  86. label: null,
  87. sortOrder: 0,
  88. isEnabled: true,
  89. lastProbedAt: null,
  90. lastProbeOk: true,
  91. lastProbeStatusCode: 200,
  92. lastProbeLatencyMs: input.lastProbeLatencyMs ?? null,
  93. lastProbeErrorType: null,
  94. lastProbeErrorMessage: null,
  95. createdAt: now,
  96. updatedAt: now,
  97. deletedAt: null,
  98. };
  99. }
  100. function createProvider(overrides: Partial<Provider> = {}): Provider {
  101. return {
  102. id: 1,
  103. name: "test-provider",
  104. url: "https://provider.example.com",
  105. key: "test-key",
  106. providerVendorId: 123,
  107. isEnabled: true,
  108. weight: 1,
  109. priority: 0,
  110. costMultiplier: 1,
  111. groupTag: null,
  112. providerType: "claude",
  113. preserveClientIp: false,
  114. modelRedirects: null,
  115. allowedModels: null,
  116. mcpPassthroughType: "none",
  117. mcpPassthroughUrl: null,
  118. limit5hUsd: null,
  119. limitDailyUsd: null,
  120. dailyResetMode: "fixed",
  121. dailyResetTime: "00:00",
  122. limitWeeklyUsd: null,
  123. limitMonthlyUsd: null,
  124. limitTotalUsd: null,
  125. totalCostResetAt: null,
  126. limitConcurrentSessions: 0,
  127. maxRetryAttempts: null,
  128. circuitBreakerFailureThreshold: 5,
  129. circuitBreakerOpenDuration: 1_800_000,
  130. circuitBreakerHalfOpenSuccessThreshold: 2,
  131. proxyUrl: null,
  132. proxyFallbackToDirect: false,
  133. firstByteTimeoutStreamingMs: 30_000,
  134. streamingIdleTimeoutMs: 10_000,
  135. requestTimeoutNonStreamingMs: 600_000,
  136. websiteUrl: null,
  137. faviconUrl: null,
  138. cacheTtlPreference: null,
  139. context1mPreference: null,
  140. codexReasoningEffortPreference: null,
  141. codexReasoningSummaryPreference: null,
  142. codexTextVerbosityPreference: null,
  143. codexParallelToolCallsPreference: null,
  144. tpm: 0,
  145. rpm: 0,
  146. rpd: 0,
  147. cc: 0,
  148. createdAt: new Date(),
  149. updatedAt: new Date(),
  150. deletedAt: null,
  151. ...overrides,
  152. };
  153. }
  154. function createSession(requestUrl: URL = new URL("https://example.com/v1/messages")): ProxySession {
  155. const headers = new Headers();
  156. const session = Object.create(ProxySession.prototype);
  157. Object.assign(session, {
  158. startTime: Date.now(),
  159. method: "POST",
  160. requestUrl,
  161. headers,
  162. originalHeaders: new Headers(headers),
  163. headerLog: JSON.stringify(Object.fromEntries(headers.entries())),
  164. request: {
  165. model: "claude-3-opus",
  166. log: "(test)",
  167. message: {
  168. model: "claude-3-opus",
  169. messages: [{ role: "user", content: "hello" }],
  170. },
  171. },
  172. userAgent: null,
  173. context: null,
  174. clientAbortSignal: null,
  175. userName: "test-user",
  176. authState: { success: true, user: null, key: null, apiKey: null },
  177. provider: null,
  178. messageContext: null,
  179. sessionId: null,
  180. requestSequence: 1,
  181. originalFormat: "claude",
  182. providerType: null,
  183. originalModelName: null,
  184. originalUrlPathname: null,
  185. providerChain: [],
  186. endpointPolicy: resolveEndpointPolicy(requestUrl.pathname),
  187. cacheTtlResolved: null,
  188. context1mApplied: false,
  189. specialSettings: [],
  190. cachedPriceData: undefined,
  191. cachedBillingModelSource: undefined,
  192. providersSnapshot: [],
  193. isHeaderModified: () => false,
  194. });
  195. return session as ProxySession;
  196. }
  197. describe("ProxyForwarder - raw passthrough policy parity (T5 RED)", () => {
  198. beforeEach(() => {
  199. vi.clearAllMocks();
  200. vi.mocked(categorizeErrorAsync).mockResolvedValue(ErrorCategory.PROVIDER_ERROR);
  201. });
  202. test.each([
  203. V1_ENDPOINT_PATHS.MESSAGES_COUNT_TOKENS,
  204. V1_ENDPOINT_PATHS.RESPONSES_COMPACT,
  205. ])("RED: %s 失败时都应统一为 no-retry/no-switch/no-circuit(Wave2 未实现前应失败)", async (pathname) => {
  206. vi.useFakeTimers();
  207. try {
  208. const session = createSession(new URL(`https://example.com${pathname}`));
  209. const provider = createProvider({
  210. providerType: "claude",
  211. providerVendorId: 123,
  212. maxRetryAttempts: 3,
  213. });
  214. session.setProvider(provider);
  215. mocks.getPreferredProviderEndpoints.mockResolvedValue([
  216. makeEndpoint({
  217. id: 1,
  218. vendorId: 123,
  219. providerType: "claude",
  220. url: "https://ep1.example.com",
  221. }),
  222. makeEndpoint({
  223. id: 2,
  224. vendorId: 123,
  225. providerType: "claude",
  226. url: "https://ep2.example.com",
  227. }),
  228. ]);
  229. const doForward = vi.spyOn(
  230. ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown },
  231. "doForward"
  232. );
  233. const selectAlternative = vi.spyOn(
  234. ProxyForwarder as unknown as { selectAlternative: (...args: unknown[]) => unknown },
  235. "selectAlternative"
  236. );
  237. doForward.mockImplementation(async () => {
  238. throw new ProxyError("upstream failed", 500);
  239. });
  240. const sendPromise = ProxyForwarder.send(session);
  241. let caughtError: Error | null = null;
  242. sendPromise.catch((error) => {
  243. caughtError = error as Error;
  244. });
  245. await vi.runAllTimersAsync();
  246. expect(caughtError).toBeInstanceOf(ProxyError);
  247. expect(doForward).toHaveBeenCalledTimes(1);
  248. expect(selectAlternative).not.toHaveBeenCalled();
  249. expect(mocks.recordFailure).not.toHaveBeenCalled();
  250. } finally {
  251. vi.useRealTimers();
  252. }
  253. });
  254. });
  255. describe("ProxyForwarder - retry limit enforcement", () => {
  256. beforeEach(() => {
  257. vi.clearAllMocks();
  258. });
  259. test("endpoints > maxRetry: should only use top N lowest-latency endpoints", async () => {
  260. vi.useFakeTimers();
  261. try {
  262. const session = createSession();
  263. // Configure provider with maxRetryAttempts=2 but 4 endpoints available
  264. const provider = createProvider({
  265. providerType: "claude",
  266. providerVendorId: 123,
  267. maxRetryAttempts: 2,
  268. });
  269. session.setProvider(provider);
  270. // Return 4 endpoints sorted by latency (lowest first)
  271. mocks.getPreferredProviderEndpoints.mockResolvedValue([
  272. makeEndpoint({
  273. id: 1,
  274. vendorId: 123,
  275. providerType: "claude",
  276. url: "https://ep1.example.com",
  277. lastProbeLatencyMs: 100,
  278. }),
  279. makeEndpoint({
  280. id: 2,
  281. vendorId: 123,
  282. providerType: "claude",
  283. url: "https://ep2.example.com",
  284. lastProbeLatencyMs: 200,
  285. }),
  286. makeEndpoint({
  287. id: 3,
  288. vendorId: 123,
  289. providerType: "claude",
  290. url: "https://ep3.example.com",
  291. lastProbeLatencyMs: 300,
  292. }),
  293. makeEndpoint({
  294. id: 4,
  295. vendorId: 123,
  296. providerType: "claude",
  297. url: "https://ep4.example.com",
  298. lastProbeLatencyMs: 400,
  299. }),
  300. ]);
  301. // Use SYSTEM_ERROR to trigger endpoint switching on retry
  302. vi.mocked(categorizeErrorAsync).mockResolvedValue(ErrorCategory.SYSTEM_ERROR);
  303. const doForward = vi.spyOn(
  304. ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown },
  305. "doForward"
  306. );
  307. // Create a network-like error
  308. const networkError = new TypeError("fetch failed");
  309. Object.assign(networkError, { code: "ECONNREFUSED" });
  310. // First attempt fails with network error, second succeeds
  311. doForward.mockImplementationOnce(async () => {
  312. throw networkError;
  313. });
  314. doForward.mockResolvedValueOnce(
  315. new Response("{}", {
  316. status: 200,
  317. headers: { "content-type": "application/json", "content-length": "2" },
  318. })
  319. );
  320. const sendPromise = ProxyForwarder.send(session);
  321. await vi.advanceTimersByTimeAsync(100);
  322. const response = await sendPromise;
  323. expect(response.status).toBe(200);
  324. // Should only call doForward twice (maxRetryAttempts=2)
  325. expect(doForward).toHaveBeenCalledTimes(2);
  326. const chain = session.getProviderChain();
  327. expect(chain).toHaveLength(2);
  328. // First attempt should use endpoint 1 (lowest latency)
  329. expect(chain[0].endpointId).toBe(1);
  330. expect(chain[0].attemptNumber).toBe(1);
  331. // Second attempt should use endpoint 2 (SYSTEM_ERROR advances endpoint)
  332. expect(chain[1].endpointId).toBe(2);
  333. expect(chain[1].attemptNumber).toBe(2);
  334. // Endpoints 3 and 4 should NOT be used
  335. } finally {
  336. vi.useRealTimers();
  337. }
  338. });
  339. test("endpoints < maxRetry: should stay at last endpoint after exhausting all (no wrap-around)", async () => {
  340. vi.useFakeTimers();
  341. try {
  342. const session = createSession();
  343. // Configure provider with maxRetryAttempts=5 but only 2 endpoints
  344. const provider = createProvider({
  345. providerType: "claude",
  346. providerVendorId: 123,
  347. maxRetryAttempts: 5,
  348. });
  349. session.setProvider(provider);
  350. mocks.getPreferredProviderEndpoints.mockResolvedValue([
  351. makeEndpoint({
  352. id: 1,
  353. vendorId: 123,
  354. providerType: "claude",
  355. url: "https://ep1.example.com",
  356. lastProbeLatencyMs: 100,
  357. }),
  358. makeEndpoint({
  359. id: 2,
  360. vendorId: 123,
  361. providerType: "claude",
  362. url: "https://ep2.example.com",
  363. lastProbeLatencyMs: 200,
  364. }),
  365. ]);
  366. // Use SYSTEM_ERROR to trigger endpoint switching
  367. vi.mocked(categorizeErrorAsync).mockResolvedValue(ErrorCategory.SYSTEM_ERROR);
  368. const doForward = vi.spyOn(
  369. ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown },
  370. "doForward"
  371. );
  372. const networkError = new TypeError("fetch failed");
  373. Object.assign(networkError, { code: "ECONNREFUSED" });
  374. // All attempts fail except the last one
  375. doForward.mockImplementation(async () => {
  376. throw networkError;
  377. });
  378. // 5th attempt succeeds
  379. doForward.mockImplementationOnce(async () => {
  380. throw networkError;
  381. });
  382. doForward.mockImplementationOnce(async () => {
  383. throw networkError;
  384. });
  385. doForward.mockImplementationOnce(async () => {
  386. throw networkError;
  387. });
  388. doForward.mockImplementationOnce(async () => {
  389. throw networkError;
  390. });
  391. doForward.mockResolvedValueOnce(
  392. new Response("{}", {
  393. status: 200,
  394. headers: { "content-type": "application/json", "content-length": "2" },
  395. })
  396. );
  397. const sendPromise = ProxyForwarder.send(session);
  398. await vi.advanceTimersByTimeAsync(500);
  399. const response = await sendPromise;
  400. expect(response.status).toBe(200);
  401. // Should call doForward 5 times (maxRetryAttempts=5)
  402. expect(doForward).toHaveBeenCalledTimes(5);
  403. const chain = session.getProviderChain();
  404. expect(chain).toHaveLength(5);
  405. // Verify NO wrap-around pattern: 1, 2, 2, 2, 2 (stays at last endpoint)
  406. expect(chain[0].endpointId).toBe(1);
  407. expect(chain[1].endpointId).toBe(2);
  408. expect(chain[2].endpointId).toBe(2); // stays at endpoint 2
  409. expect(chain[3].endpointId).toBe(2);
  410. expect(chain[4].endpointId).toBe(2);
  411. } finally {
  412. vi.useRealTimers();
  413. }
  414. });
  415. test("endpoints = maxRetry: each endpoint should be tried exactly once with SYSTEM_ERROR", async () => {
  416. vi.useFakeTimers();
  417. try {
  418. const session = createSession();
  419. // Configure provider with maxRetryAttempts=3 and 3 endpoints
  420. const provider = createProvider({
  421. providerType: "claude",
  422. providerVendorId: 123,
  423. maxRetryAttempts: 3,
  424. });
  425. session.setProvider(provider);
  426. mocks.getPreferredProviderEndpoints.mockResolvedValue([
  427. makeEndpoint({
  428. id: 1,
  429. vendorId: 123,
  430. providerType: "claude",
  431. url: "https://ep1.example.com",
  432. lastProbeLatencyMs: 100,
  433. }),
  434. makeEndpoint({
  435. id: 2,
  436. vendorId: 123,
  437. providerType: "claude",
  438. url: "https://ep2.example.com",
  439. lastProbeLatencyMs: 200,
  440. }),
  441. makeEndpoint({
  442. id: 3,
  443. vendorId: 123,
  444. providerType: "claude",
  445. url: "https://ep3.example.com",
  446. lastProbeLatencyMs: 300,
  447. }),
  448. ]);
  449. // Use SYSTEM_ERROR to trigger endpoint switching
  450. vi.mocked(categorizeErrorAsync).mockResolvedValue(ErrorCategory.SYSTEM_ERROR);
  451. const doForward = vi.spyOn(
  452. ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown },
  453. "doForward"
  454. );
  455. const networkError = new TypeError("fetch failed");
  456. Object.assign(networkError, { code: "ECONNREFUSED" });
  457. // First two fail with network error, third succeeds
  458. doForward.mockImplementationOnce(async () => {
  459. throw networkError;
  460. });
  461. doForward.mockImplementationOnce(async () => {
  462. throw networkError;
  463. });
  464. doForward.mockResolvedValueOnce(
  465. new Response("{}", {
  466. status: 200,
  467. headers: { "content-type": "application/json", "content-length": "2" },
  468. })
  469. );
  470. const sendPromise = ProxyForwarder.send(session);
  471. await vi.advanceTimersByTimeAsync(300);
  472. const response = await sendPromise;
  473. expect(response.status).toBe(200);
  474. expect(doForward).toHaveBeenCalledTimes(3);
  475. const chain = session.getProviderChain();
  476. expect(chain).toHaveLength(3);
  477. // Each endpoint tried exactly once (SYSTEM_ERROR advances endpoint)
  478. expect(chain[0].endpointId).toBe(1);
  479. expect(chain[1].endpointId).toBe(2);
  480. expect(chain[2].endpointId).toBe(3);
  481. } finally {
  482. vi.useRealTimers();
  483. }
  484. });
  485. test("MCP request: should use provider.url only, ignore vendor endpoints", async () => {
  486. const session = createSession(new URL("https://example.com/mcp/custom-endpoint"));
  487. const provider = createProvider({
  488. providerType: "claude",
  489. providerVendorId: 123,
  490. maxRetryAttempts: 2,
  491. url: "https://provider.example.com/mcp",
  492. });
  493. session.setProvider(provider);
  494. // Even if endpoints are available, MCP should not use them
  495. mocks.getPreferredProviderEndpoints.mockResolvedValue([
  496. makeEndpoint({
  497. id: 1,
  498. vendorId: 123,
  499. providerType: "claude",
  500. url: "https://ep1.example.com",
  501. }),
  502. makeEndpoint({
  503. id: 2,
  504. vendorId: 123,
  505. providerType: "claude",
  506. url: "https://ep2.example.com",
  507. }),
  508. ]);
  509. const doForward = vi.spyOn(
  510. ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown },
  511. "doForward"
  512. );
  513. doForward.mockResolvedValueOnce(
  514. new Response("{}", {
  515. status: 200,
  516. headers: { "content-type": "application/json", "content-length": "2" },
  517. })
  518. );
  519. const response = await ProxyForwarder.send(session);
  520. expect(response.status).toBe(200);
  521. // getPreferredProviderEndpoints should NOT be called for MCP requests
  522. expect(mocks.getPreferredProviderEndpoints).not.toHaveBeenCalled();
  523. const chain = session.getProviderChain();
  524. expect(chain).toHaveLength(1);
  525. // endpointId should be null (using provider.url)
  526. expect(chain[0].endpointId).toBeNull();
  527. });
  528. test("no vendor endpoints: should use provider.url with configured maxRetry", async () => {
  529. vi.useFakeTimers();
  530. try {
  531. const session = createSession();
  532. // Provider without vendorId
  533. const provider = createProvider({
  534. providerType: "claude",
  535. providerVendorId: null as unknown as number,
  536. maxRetryAttempts: 3,
  537. url: "https://provider.example.com",
  538. });
  539. session.setProvider(provider);
  540. const doForward = vi.spyOn(
  541. ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown },
  542. "doForward"
  543. );
  544. // First two fail, third succeeds
  545. doForward.mockImplementationOnce(async () => {
  546. throw new ProxyError("failed", 500);
  547. });
  548. doForward.mockImplementationOnce(async () => {
  549. throw new ProxyError("failed", 500);
  550. });
  551. doForward.mockResolvedValueOnce(
  552. new Response("{}", {
  553. status: 200,
  554. headers: { "content-type": "application/json", "content-length": "2" },
  555. })
  556. );
  557. const sendPromise = ProxyForwarder.send(session);
  558. await vi.advanceTimersByTimeAsync(300);
  559. const response = await sendPromise;
  560. expect(response.status).toBe(200);
  561. // Should retry up to maxRetryAttempts times
  562. expect(doForward).toHaveBeenCalledTimes(3);
  563. // getPreferredProviderEndpoints should NOT be called (no vendorId)
  564. expect(mocks.getPreferredProviderEndpoints).not.toHaveBeenCalled();
  565. const chain = session.getProviderChain();
  566. expect(chain).toHaveLength(3);
  567. // All attempts should use provider.url (endpointId=null)
  568. expect(chain[0].endpointId).toBeNull();
  569. expect(chain[1].endpointId).toBeNull();
  570. expect(chain[2].endpointId).toBeNull();
  571. } finally {
  572. vi.useRealTimers();
  573. }
  574. });
  575. test("all retries exhausted: should not exceed maxRetryAttempts", async () => {
  576. vi.useFakeTimers();
  577. try {
  578. const session = createSession();
  579. const provider = createProvider({
  580. providerType: "claude",
  581. providerVendorId: 123,
  582. maxRetryAttempts: 2,
  583. });
  584. session.setProvider(provider);
  585. // 4 endpoints available but maxRetry=2
  586. mocks.getPreferredProviderEndpoints.mockResolvedValue([
  587. makeEndpoint({
  588. id: 1,
  589. vendorId: 123,
  590. providerType: "claude",
  591. url: "https://ep1.example.com",
  592. lastProbeLatencyMs: 100,
  593. }),
  594. makeEndpoint({
  595. id: 2,
  596. vendorId: 123,
  597. providerType: "claude",
  598. url: "https://ep2.example.com",
  599. lastProbeLatencyMs: 200,
  600. }),
  601. makeEndpoint({
  602. id: 3,
  603. vendorId: 123,
  604. providerType: "claude",
  605. url: "https://ep3.example.com",
  606. lastProbeLatencyMs: 300,
  607. }),
  608. makeEndpoint({
  609. id: 4,
  610. vendorId: 123,
  611. providerType: "claude",
  612. url: "https://ep4.example.com",
  613. lastProbeLatencyMs: 400,
  614. }),
  615. ]);
  616. // Use SYSTEM_ERROR to trigger endpoint switching
  617. vi.mocked(categorizeErrorAsync).mockResolvedValue(ErrorCategory.SYSTEM_ERROR);
  618. const doForward = vi.spyOn(
  619. ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown },
  620. "doForward"
  621. );
  622. const networkError = new TypeError("fetch failed");
  623. Object.assign(networkError, { code: "ECONNREFUSED" });
  624. // All attempts fail
  625. doForward.mockImplementation(async () => {
  626. throw networkError;
  627. });
  628. const sendPromise = ProxyForwarder.send(session);
  629. // Attach catch handler immediately to prevent unhandled rejection warnings
  630. let caughtError: Error | null = null;
  631. sendPromise.catch((e) => {
  632. caughtError = e;
  633. });
  634. await vi.runAllTimersAsync();
  635. expect(caughtError).not.toBeNull();
  636. expect(caughtError).toBeInstanceOf(ProxyError);
  637. // Should only call doForward twice (maxRetryAttempts=2), NOT 4 times
  638. expect(doForward).toHaveBeenCalledTimes(2);
  639. const chain = session.getProviderChain();
  640. // Only 2 attempts recorded
  641. expect(chain).toHaveLength(2);
  642. expect(chain[0].endpointId).toBe(1);
  643. expect(chain[1].endpointId).toBe(2);
  644. } finally {
  645. vi.useRealTimers();
  646. }
  647. });
  648. test("524 with endpoint pool: should keep retrying until maxRetryAttempts before vendor_type_all_timeout", async () => {
  649. vi.useFakeTimers();
  650. try {
  651. const session = createSession();
  652. const provider = createProvider({
  653. providerType: "claude",
  654. providerVendorId: 123,
  655. maxRetryAttempts: 5,
  656. });
  657. session.setProvider(provider);
  658. mocks.getPreferredProviderEndpoints.mockResolvedValue([
  659. makeEndpoint({
  660. id: 1,
  661. vendorId: 123,
  662. providerType: "claude",
  663. url: "https://ep1.example.com",
  664. lastProbeLatencyMs: 100,
  665. }),
  666. makeEndpoint({
  667. id: 2,
  668. vendorId: 123,
  669. providerType: "claude",
  670. url: "https://ep2.example.com",
  671. lastProbeLatencyMs: 200,
  672. }),
  673. ]);
  674. vi.mocked(categorizeErrorAsync).mockResolvedValue(ErrorCategory.PROVIDER_ERROR);
  675. const doForward = vi.spyOn(
  676. ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown },
  677. "doForward"
  678. );
  679. doForward.mockImplementation(async () => {
  680. throw new ProxyError("provider timeout", 524, {
  681. body: "",
  682. providerId: provider.id,
  683. providerName: provider.name,
  684. });
  685. });
  686. const sendPromise = ProxyForwarder.send(session);
  687. let caughtError: Error | null = null;
  688. sendPromise.catch((error) => {
  689. caughtError = error as Error;
  690. });
  691. await vi.runAllTimersAsync();
  692. expect(caughtError).not.toBeNull();
  693. expect(caughtError).toBeInstanceOf(ProxyError);
  694. expect(doForward).toHaveBeenCalledTimes(5);
  695. const chain = session.getProviderChain();
  696. expect(chain).toHaveLength(5);
  697. expect(chain.map((item) => item.endpointId)).toEqual([1, 2, 2, 2, 2]);
  698. const vendorTimeoutItems = chain.filter((item) => item.reason === "vendor_type_all_timeout");
  699. expect(vendorTimeoutItems).toHaveLength(1);
  700. expect(vendorTimeoutItems[0]?.attemptNumber).toBe(5);
  701. expect(mocks.recordVendorTypeAllEndpointsTimeout).toHaveBeenCalledTimes(1);
  702. } finally {
  703. vi.useRealTimers();
  704. }
  705. });
  706. test("524 with endpoint pool: maxRetryAttempts=2 should stop at budget exhaustion", async () => {
  707. vi.useFakeTimers();
  708. try {
  709. const session = createSession();
  710. const provider = createProvider({
  711. providerType: "claude",
  712. providerVendorId: 123,
  713. maxRetryAttempts: 2,
  714. });
  715. session.setProvider(provider);
  716. mocks.getPreferredProviderEndpoints.mockResolvedValue([
  717. makeEndpoint({
  718. id: 1,
  719. vendorId: 123,
  720. providerType: "claude",
  721. url: "https://ep1.example.com",
  722. lastProbeLatencyMs: 100,
  723. }),
  724. makeEndpoint({
  725. id: 2,
  726. vendorId: 123,
  727. providerType: "claude",
  728. url: "https://ep2.example.com",
  729. lastProbeLatencyMs: 200,
  730. }),
  731. ]);
  732. vi.mocked(categorizeErrorAsync).mockResolvedValue(ErrorCategory.PROVIDER_ERROR);
  733. const doForward = vi.spyOn(
  734. ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown },
  735. "doForward"
  736. );
  737. doForward.mockImplementation(async () => {
  738. throw new ProxyError("provider timeout", 524, {
  739. body: "",
  740. providerId: provider.id,
  741. providerName: provider.name,
  742. });
  743. });
  744. const sendPromise = ProxyForwarder.send(session);
  745. let caughtError: Error | null = null;
  746. sendPromise.catch((error) => {
  747. caughtError = error as Error;
  748. });
  749. await vi.runAllTimersAsync();
  750. expect(caughtError).not.toBeNull();
  751. expect(caughtError).toBeInstanceOf(ProxyError);
  752. expect(doForward).toHaveBeenCalledTimes(2);
  753. const chain = session.getProviderChain();
  754. expect(chain.map((item) => item.endpointId)).toEqual([1, 2]);
  755. const vendorTimeoutItems = chain.filter((item) => item.reason === "vendor_type_all_timeout");
  756. expect(vendorTimeoutItems).toHaveLength(1);
  757. expect(vendorTimeoutItems[0]?.attemptNumber).toBe(2);
  758. expect(mocks.recordVendorTypeAllEndpointsTimeout).toHaveBeenCalledTimes(1);
  759. } finally {
  760. vi.useRealTimers();
  761. }
  762. });
  763. });
  764. describe("ProxyForwarder - endpoint stickiness on retry", () => {
  765. beforeEach(() => {
  766. vi.clearAllMocks();
  767. // Reset to default PROVIDER_ERROR behavior
  768. vi.mocked(categorizeErrorAsync).mockResolvedValue(ErrorCategory.PROVIDER_ERROR);
  769. });
  770. test("SYSTEM_ERROR: should switch to next endpoint on each network error retry", async () => {
  771. vi.useFakeTimers();
  772. try {
  773. const session = createSession();
  774. const provider = createProvider({
  775. providerType: "claude",
  776. providerVendorId: 123,
  777. maxRetryAttempts: 3,
  778. });
  779. session.setProvider(provider);
  780. // 3 endpoints sorted by latency
  781. mocks.getPreferredProviderEndpoints.mockResolvedValue([
  782. makeEndpoint({
  783. id: 1,
  784. vendorId: 123,
  785. providerType: "claude",
  786. url: "https://ep1.example.com",
  787. lastProbeLatencyMs: 100,
  788. }),
  789. makeEndpoint({
  790. id: 2,
  791. vendorId: 123,
  792. providerType: "claude",
  793. url: "https://ep2.example.com",
  794. lastProbeLatencyMs: 200,
  795. }),
  796. makeEndpoint({
  797. id: 3,
  798. vendorId: 123,
  799. providerType: "claude",
  800. url: "https://ep3.example.com",
  801. lastProbeLatencyMs: 300,
  802. }),
  803. ]);
  804. // Mock categorizeErrorAsync to return SYSTEM_ERROR (network error)
  805. vi.mocked(categorizeErrorAsync).mockResolvedValue(ErrorCategory.SYSTEM_ERROR);
  806. const doForward = vi.spyOn(
  807. ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown },
  808. "doForward"
  809. );
  810. // Create a network-like error (not ProxyError)
  811. const networkError = new TypeError("fetch failed");
  812. Object.assign(networkError, { code: "ECONNREFUSED" });
  813. // First two fail with network error, third succeeds
  814. doForward.mockImplementationOnce(async () => {
  815. throw networkError;
  816. });
  817. doForward.mockImplementationOnce(async () => {
  818. throw networkError;
  819. });
  820. doForward.mockResolvedValueOnce(
  821. new Response("{}", {
  822. status: 200,
  823. headers: { "content-type": "application/json", "content-length": "2" },
  824. })
  825. );
  826. const sendPromise = ProxyForwarder.send(session);
  827. await vi.advanceTimersByTimeAsync(300);
  828. const response = await sendPromise;
  829. expect(response.status).toBe(200);
  830. expect(doForward).toHaveBeenCalledTimes(3);
  831. const chain = session.getProviderChain();
  832. expect(chain).toHaveLength(3);
  833. // Network error should switch to next endpoint on each retry
  834. // attempt 1: endpoint 1, attempt 2: endpoint 2, attempt 3: endpoint 3
  835. expect(chain[0].endpointId).toBe(1);
  836. expect(chain[0].attemptNumber).toBe(1);
  837. expect(chain[1].endpointId).toBe(2);
  838. expect(chain[1].attemptNumber).toBe(2);
  839. expect(chain[2].endpointId).toBe(3);
  840. expect(chain[2].attemptNumber).toBe(3);
  841. } finally {
  842. vi.useRealTimers();
  843. }
  844. });
  845. test("PROVIDER_ERROR: should keep same endpoint on non-network error retry", async () => {
  846. vi.useFakeTimers();
  847. try {
  848. const session = createSession();
  849. const provider = createProvider({
  850. providerType: "claude",
  851. providerVendorId: 123,
  852. maxRetryAttempts: 3,
  853. });
  854. session.setProvider(provider);
  855. // 3 endpoints sorted by latency
  856. mocks.getPreferredProviderEndpoints.mockResolvedValue([
  857. makeEndpoint({
  858. id: 1,
  859. vendorId: 123,
  860. providerType: "claude",
  861. url: "https://ep1.example.com",
  862. lastProbeLatencyMs: 100,
  863. }),
  864. makeEndpoint({
  865. id: 2,
  866. vendorId: 123,
  867. providerType: "claude",
  868. url: "https://ep2.example.com",
  869. lastProbeLatencyMs: 200,
  870. }),
  871. makeEndpoint({
  872. id: 3,
  873. vendorId: 123,
  874. providerType: "claude",
  875. url: "https://ep3.example.com",
  876. lastProbeLatencyMs: 300,
  877. }),
  878. ]);
  879. // Mock categorizeErrorAsync to return PROVIDER_ERROR (HTTP error, not network)
  880. vi.mocked(categorizeErrorAsync).mockResolvedValue(ErrorCategory.PROVIDER_ERROR);
  881. const doForward = vi.spyOn(
  882. ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown },
  883. "doForward"
  884. );
  885. // First two fail with HTTP 500, third succeeds
  886. doForward.mockImplementationOnce(async () => {
  887. throw new ProxyError("server error", 500);
  888. });
  889. doForward.mockImplementationOnce(async () => {
  890. throw new ProxyError("server error", 500);
  891. });
  892. doForward.mockResolvedValueOnce(
  893. new Response("{}", {
  894. status: 200,
  895. headers: { "content-type": "application/json", "content-length": "2" },
  896. })
  897. );
  898. const sendPromise = ProxyForwarder.send(session);
  899. await vi.advanceTimersByTimeAsync(300);
  900. const response = await sendPromise;
  901. expect(response.status).toBe(200);
  902. expect(doForward).toHaveBeenCalledTimes(3);
  903. const chain = session.getProviderChain();
  904. expect(chain).toHaveLength(3);
  905. // Non-network error should keep same endpoint on all retries
  906. // All 3 attempts should use endpoint 1 (sticky)
  907. expect(chain[0].endpointId).toBe(1);
  908. expect(chain[0].attemptNumber).toBe(1);
  909. expect(chain[1].endpointId).toBe(1);
  910. expect(chain[1].attemptNumber).toBe(2);
  911. expect(chain[2].endpointId).toBe(1);
  912. expect(chain[2].attemptNumber).toBe(3);
  913. } finally {
  914. vi.useRealTimers();
  915. }
  916. });
  917. test("SYSTEM_ERROR: should not wrap around when endpoints exhausted", async () => {
  918. vi.useFakeTimers();
  919. try {
  920. const session = createSession();
  921. const provider = createProvider({
  922. providerType: "claude",
  923. providerVendorId: 123,
  924. maxRetryAttempts: 4, // More retries than endpoints
  925. });
  926. session.setProvider(provider);
  927. // Only 2 endpoints available
  928. mocks.getPreferredProviderEndpoints.mockResolvedValue([
  929. makeEndpoint({
  930. id: 1,
  931. vendorId: 123,
  932. providerType: "claude",
  933. url: "https://ep1.example.com",
  934. lastProbeLatencyMs: 100,
  935. }),
  936. makeEndpoint({
  937. id: 2,
  938. vendorId: 123,
  939. providerType: "claude",
  940. url: "https://ep2.example.com",
  941. lastProbeLatencyMs: 200,
  942. }),
  943. ]);
  944. // Mock categorizeErrorAsync to return SYSTEM_ERROR (network error)
  945. vi.mocked(categorizeErrorAsync).mockResolvedValue(ErrorCategory.SYSTEM_ERROR);
  946. const doForward = vi.spyOn(
  947. ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown },
  948. "doForward"
  949. );
  950. // Create a network-like error
  951. const networkError = new TypeError("fetch failed");
  952. Object.assign(networkError, { code: "ETIMEDOUT" });
  953. // All 4 attempts fail with network error, then mock switches provider
  954. doForward.mockImplementation(async () => {
  955. throw networkError;
  956. });
  957. const sendPromise = ProxyForwarder.send(session);
  958. // Attach catch handler immediately to prevent unhandled rejection warnings
  959. let caughtError: Error | null = null;
  960. sendPromise.catch((e) => {
  961. caughtError = e;
  962. });
  963. await vi.runAllTimersAsync();
  964. // Should fail eventually (no successful response)
  965. expect(caughtError).not.toBeNull();
  966. expect(caughtError).toBeInstanceOf(ProxyError);
  967. const chain = session.getProviderChain();
  968. // Should have attempted with both endpoints, but NOT wrap around
  969. // Pattern should be: endpoint 1, endpoint 2, endpoint 2, endpoint 2 (stay at last)
  970. // NOT: endpoint 1, endpoint 2, endpoint 1, endpoint 2 (wrap around)
  971. expect(chain.length).toBeGreaterThanOrEqual(2);
  972. // First attempt uses endpoint 1
  973. expect(chain[0].endpointId).toBe(1);
  974. // Second attempt uses endpoint 2
  975. expect(chain[1].endpointId).toBe(2);
  976. // Subsequent attempts should stay at endpoint 2 (no wrap-around)
  977. if (chain.length > 2) {
  978. expect(chain[2].endpointId).toBe(2);
  979. }
  980. if (chain.length > 3) {
  981. expect(chain[3].endpointId).toBe(2);
  982. }
  983. } finally {
  984. vi.useRealTimers();
  985. }
  986. });
  987. test("mixed errors: PROVIDER_ERROR should not advance endpoint index", async () => {
  988. vi.useFakeTimers();
  989. try {
  990. const session = createSession();
  991. const provider = createProvider({
  992. providerType: "claude",
  993. providerVendorId: 123,
  994. maxRetryAttempts: 4,
  995. });
  996. session.setProvider(provider);
  997. // 3 endpoints
  998. mocks.getPreferredProviderEndpoints.mockResolvedValue([
  999. makeEndpoint({
  1000. id: 1,
  1001. vendorId: 123,
  1002. providerType: "claude",
  1003. url: "https://ep1.example.com",
  1004. lastProbeLatencyMs: 100,
  1005. }),
  1006. makeEndpoint({
  1007. id: 2,
  1008. vendorId: 123,
  1009. providerType: "claude",
  1010. url: "https://ep2.example.com",
  1011. lastProbeLatencyMs: 200,
  1012. }),
  1013. makeEndpoint({
  1014. id: 3,
  1015. vendorId: 123,
  1016. providerType: "claude",
  1017. url: "https://ep3.example.com",
  1018. lastProbeLatencyMs: 300,
  1019. }),
  1020. ]);
  1021. const doForward = vi.spyOn(
  1022. ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown },
  1023. "doForward"
  1024. );
  1025. // Create errors
  1026. const networkError = new TypeError("fetch failed");
  1027. Object.assign(networkError, { code: "ECONNREFUSED" });
  1028. const httpError = new ProxyError("server error", 500);
  1029. // Attempt 1: SYSTEM_ERROR (switch endpoint)
  1030. // Attempt 2: PROVIDER_ERROR (keep endpoint)
  1031. // Attempt 3: SYSTEM_ERROR (switch endpoint)
  1032. // Attempt 4: success
  1033. let attemptCount = 0;
  1034. vi.mocked(categorizeErrorAsync).mockImplementation(async () => {
  1035. attemptCount++;
  1036. if (attemptCount === 1) return ErrorCategory.SYSTEM_ERROR;
  1037. if (attemptCount === 2) return ErrorCategory.PROVIDER_ERROR;
  1038. if (attemptCount === 3) return ErrorCategory.SYSTEM_ERROR;
  1039. return ErrorCategory.PROVIDER_ERROR;
  1040. });
  1041. doForward.mockImplementationOnce(async () => {
  1042. throw networkError; // SYSTEM_ERROR -> advance to ep2
  1043. });
  1044. doForward.mockImplementationOnce(async () => {
  1045. throw httpError; // PROVIDER_ERROR -> stay at ep2
  1046. });
  1047. doForward.mockImplementationOnce(async () => {
  1048. throw networkError; // SYSTEM_ERROR -> advance to ep3
  1049. });
  1050. doForward.mockResolvedValueOnce(
  1051. new Response("{}", {
  1052. status: 200,
  1053. headers: { "content-type": "application/json", "content-length": "2" },
  1054. })
  1055. );
  1056. const sendPromise = ProxyForwarder.send(session);
  1057. await vi.advanceTimersByTimeAsync(500);
  1058. const response = await sendPromise;
  1059. expect(response.status).toBe(200);
  1060. expect(doForward).toHaveBeenCalledTimes(4);
  1061. const chain = session.getProviderChain();
  1062. expect(chain).toHaveLength(4);
  1063. // Verify endpoint progression:
  1064. // attempt 1: ep1 (SYSTEM_ERROR -> advance)
  1065. // attempt 2: ep2 (PROVIDER_ERROR -> stay)
  1066. // attempt 3: ep2 (SYSTEM_ERROR -> advance)
  1067. // attempt 4: ep3 (success)
  1068. expect(chain[0].endpointId).toBe(1);
  1069. expect(chain[1].endpointId).toBe(2);
  1070. expect(chain[2].endpointId).toBe(2);
  1071. expect(chain[3].endpointId).toBe(3);
  1072. } finally {
  1073. vi.useRealTimers();
  1074. }
  1075. });
  1076. });
  1077. describe("NON_RETRYABLE_CLIENT_ERROR regression tests", () => {
  1078. beforeEach(() => {
  1079. vi.clearAllMocks();
  1080. mocks.getPreferredProviderEndpoints.mockResolvedValue([
  1081. makeEndpoint({ id: 1, vendorId: 1, providerType: "claude", url: "https://ep1.example.com" }),
  1082. ]);
  1083. });
  1084. test("NON_RETRYABLE_CLIENT_ERROR with plain SocketError does not throw TypeError", async () => {
  1085. // 1. Build a synthetic native transport error
  1086. const socketErr = Object.assign(new Error("other side closed"), {
  1087. name: "SocketError",
  1088. code: "UND_ERR_SOCKET",
  1089. });
  1090. // 2. doForward always throws it
  1091. const doForward = vi.spyOn(ProxyForwarder as unknown as { doForward: unknown }, "doForward");
  1092. doForward.mockRejectedValue(socketErr);
  1093. // 3. Force categorizeErrorAsync to return NON_RETRYABLE_CLIENT_ERROR (simulates regression)
  1094. vi.mocked(categorizeErrorAsync).mockResolvedValue(ErrorCategory.NON_RETRYABLE_CLIENT_ERROR);
  1095. const session = createSession(new URL("https://example.com/v1/messages"));
  1096. const provider = createProvider({ id: 1, providerType: "claude", providerVendorId: 1 });
  1097. session.setProvider(provider);
  1098. // 4. Expect the original SocketError to be rethrown, NOT a TypeError
  1099. await expect(ProxyForwarder.send(session)).rejects.toSatisfy((e: unknown) => {
  1100. expect(e).toBe(socketErr); // same object reference
  1101. return true;
  1102. });
  1103. // 5. Provider chain should have been recorded
  1104. expect(session.getProviderChain()).toHaveLength(1);
  1105. expect(session.getProviderChain()[0].reason).toBe("client_error_non_retryable");
  1106. });
  1107. test("categorizeErrorAsync returns SYSTEM_ERROR for SocketError even if rule would match", async () => {
  1108. // Import real categorizeErrorAsync (not the mock from the forwarder test)
  1109. const { categorizeErrorAsync: realCategorize, ErrorCategory: RealErrorCategory } =
  1110. await vi.importActual<typeof import("@/app/v1/_lib/proxy/errors")>(
  1111. "@/app/v1/_lib/proxy/errors"
  1112. );
  1113. const socketErr = Object.assign(new Error("other side closed"), {
  1114. name: "SocketError",
  1115. code: "UND_ERR_SOCKET",
  1116. });
  1117. const category = await realCategorize(socketErr);
  1118. expect(category).toBe(RealErrorCategory.SYSTEM_ERROR);
  1119. });
  1120. test("NON_RETRYABLE_CLIENT_ERROR should log matched rule metadata when detection result is available", async () => {
  1121. const upstreamMessage =
  1122. "Your session is missing thinking fields juedged by Anthropic API. Please reopen a new session.";
  1123. const proxyError = new ProxyError("Provider returned 400", 400, {
  1124. body: JSON.stringify({
  1125. error: {
  1126. message: upstreamMessage,
  1127. },
  1128. }),
  1129. parsed: {
  1130. error: {
  1131. message: upstreamMessage,
  1132. },
  1133. },
  1134. providerId: 1,
  1135. providerName: "YesCode Team Claude",
  1136. });
  1137. const doForward = vi.spyOn(ProxyForwarder as unknown as { doForward: unknown }, "doForward");
  1138. doForward.mockRejectedValue(proxyError);
  1139. vi.mocked(categorizeErrorAsync).mockResolvedValue(ErrorCategory.NON_RETRYABLE_CLIENT_ERROR);
  1140. vi.mocked(getErrorDetectionResultAsync).mockResolvedValue({
  1141. matched: true,
  1142. ruleId: 42,
  1143. category: "thinking_error",
  1144. pattern: "missing thinking fields",
  1145. matchType: "contains",
  1146. description: "YesCode missing thinking fields",
  1147. overrideResponse: {
  1148. type: "error",
  1149. error: {
  1150. type: "thinking_error",
  1151. message: "thinking block 缺失",
  1152. },
  1153. },
  1154. overrideStatusCode: 400,
  1155. });
  1156. const session = createSession(new URL("https://example.com/v1/messages"));
  1157. const provider = createProvider({
  1158. id: 1,
  1159. name: "YesCode Team Claude",
  1160. providerType: "claude",
  1161. providerVendorId: 1,
  1162. });
  1163. session.setProvider(provider);
  1164. await expect(ProxyForwarder.send(session)).rejects.toBe(proxyError);
  1165. expect(vi.mocked(logger.warn)).toHaveBeenCalledWith(
  1166. "ProxyForwarder: Non-retryable client error, stopping immediately",
  1167. expect.objectContaining({
  1168. matchedRuleId: 42,
  1169. matchedRuleName: "YesCode missing thinking fields",
  1170. matchedRulePattern: "missing thinking fields",
  1171. matchedRuleCategory: "thinking_error",
  1172. matchedRuleMatchType: "contains",
  1173. matchedRuleHasOverrideResponse: true,
  1174. matchedRuleHasOverrideStatusCode: true,
  1175. })
  1176. );
  1177. expect(session.getProviderChain()[0].errorDetails?.matchedRule?.ruleId).toBe(42);
  1178. });
  1179. test("NON_RETRYABLE_CLIENT_ERROR should log matched rule metadata for plain Error branch", async () => {
  1180. const plainError = new Error("missing thinking fields");
  1181. const doForward = vi.spyOn(ProxyForwarder as unknown as { doForward: unknown }, "doForward");
  1182. doForward.mockRejectedValue(plainError);
  1183. vi.mocked(categorizeErrorAsync).mockResolvedValue(ErrorCategory.NON_RETRYABLE_CLIENT_ERROR);
  1184. vi.mocked(getErrorDetectionResultAsync).mockResolvedValue({
  1185. matched: true,
  1186. ruleId: 42,
  1187. category: "thinking_error",
  1188. pattern: "missing thinking fields",
  1189. matchType: "contains",
  1190. description: "YesCode missing thinking fields",
  1191. overrideStatusCode: 400,
  1192. });
  1193. const session = createSession(new URL("https://example.com/v1/messages"));
  1194. const provider = createProvider({
  1195. id: 1,
  1196. name: "YesCode Team Claude",
  1197. providerType: "claude",
  1198. providerVendorId: 1,
  1199. });
  1200. session.setProvider(provider);
  1201. await expect(ProxyForwarder.send(session)).rejects.toBe(plainError);
  1202. expect(vi.mocked(logger.warn)).toHaveBeenCalledWith(
  1203. "ProxyForwarder: Non-retryable client error (plain error), stopping immediately",
  1204. expect.objectContaining({
  1205. matchedRuleId: 42,
  1206. matchedRuleName: "YesCode missing thinking fields",
  1207. matchedRulePattern: "missing thinking fields",
  1208. matchedRuleCategory: "thinking_error",
  1209. matchedRuleMatchType: "contains",
  1210. matchedRuleHasOverrideResponse: false,
  1211. matchedRuleHasOverrideStatusCode: true,
  1212. })
  1213. );
  1214. expect(session.getProviderChain()[0].errorDetails?.matchedRule?.ruleId).toBe(42);
  1215. });
  1216. });