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

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