response-handler-non200.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  1. /**
  2. * Tests for non-200 status code handling in response-handler.ts
  3. *
  4. * Verifies that:
  5. * - Non-200 responses trigger circuit breaker recording
  6. * - JSON error responses are parsed correctly
  7. * - Provider chain is updated with error info
  8. * - Error messages are captured for logging
  9. */
  10. import { beforeEach, describe, expect, it, vi } from "vitest";
  11. import type { ModelPriceData } from "@/types/model-price";
  12. import type { Provider } from "@/types/provider";
  13. import { ProxySession } from "@/app/v1/_lib/proxy/session";
  14. import { detectUpstreamErrorFromSseOrJsonText } from "@/lib/utils/upstream-error-detection";
  15. // Track async tasks for draining
  16. const asyncTasks: Promise<void>[] = [];
  17. vi.mock("@/lib/async-task-manager", () => ({
  18. AsyncTaskManager: {
  19. register: (_taskId: string, promise: Promise<void>) => {
  20. asyncTasks.push(promise);
  21. return new AbortController();
  22. },
  23. cleanup: () => {},
  24. cancel: () => {},
  25. },
  26. }));
  27. vi.mock("@/lib/logger", () => ({
  28. logger: {
  29. debug: () => {},
  30. info: () => {},
  31. warn: () => {},
  32. error: () => {},
  33. trace: () => {},
  34. },
  35. }));
  36. vi.mock("@/lib/price-sync/cloud-price-updater", () => ({
  37. requestCloudPriceTableSync: () => {},
  38. }));
  39. vi.mock("@/repository/model-price", () => ({
  40. findLatestPriceByModel: vi.fn(),
  41. }));
  42. vi.mock("@/repository/system-config", () => ({
  43. getSystemSettings: vi.fn(),
  44. }));
  45. vi.mock("@/repository/message", () => ({
  46. updateMessageRequestCost: vi.fn(),
  47. updateMessageRequestCostWithBreakdown: vi.fn(),
  48. updateMessageRequestDetails: vi.fn(),
  49. updateMessageRequestDuration: vi.fn(),
  50. }));
  51. vi.mock("@/lib/session-manager", () => ({
  52. SessionManager: {
  53. updateSessionUsage: vi.fn(),
  54. storeSessionResponse: vi.fn(),
  55. extractCodexPromptCacheKey: vi.fn(),
  56. updateSessionWithCodexCacheKey: vi.fn(),
  57. },
  58. }));
  59. vi.mock("@/lib/rate-limit", () => ({
  60. RateLimitService: {
  61. trackCost: vi.fn(),
  62. trackUserDailyCost: vi.fn(),
  63. decrementLeaseBudget: vi.fn(),
  64. },
  65. }));
  66. vi.mock("@/lib/session-tracker", () => ({
  67. SessionTracker: {
  68. refreshSession: vi.fn(),
  69. },
  70. }));
  71. vi.mock("@/lib/proxy-status-tracker", () => ({
  72. ProxyStatusTracker: {
  73. getInstance: () => ({
  74. endRequest: () => {},
  75. }),
  76. },
  77. }));
  78. // Mock circuit breaker before import
  79. const mockRecordFailure = vi.fn();
  80. vi.mock("@/lib/circuit-breaker", () => ({
  81. recordFailure: mockRecordFailure,
  82. }));
  83. vi.mock("@/lib/endpoint-circuit-breaker", () => ({
  84. recordEndpointFailure: vi.fn(),
  85. }));
  86. // Test price data
  87. const testPriceData: ModelPriceData = {
  88. input_cost_per_token: 0.000003,
  89. output_cost_per_token: 0.000015,
  90. };
  91. function createSession(opts: {
  92. originalModel?: string;
  93. redirectedModel?: string;
  94. sessionId?: string | null;
  95. messageId?: string;
  96. provider?: Provider;
  97. messageContext?: ProxySession["messageContext"];
  98. }): ProxySession {
  99. const {
  100. originalModel = "test-model",
  101. redirectedModel = "test-model",
  102. sessionId = null,
  103. messageId = "msg-123",
  104. provider,
  105. messageContext,
  106. } = opts;
  107. // Use defaults if not provided
  108. const effectiveProvider = provider ?? {
  109. id: 1,
  110. name: "test-provider",
  111. providerType: "openai" as const,
  112. baseUrl: "https://api.test.com",
  113. priority: 10,
  114. weight: 1,
  115. costMultiplier: 1,
  116. groupTag: "default",
  117. isEnabled: true,
  118. models: [],
  119. createdAt: new Date(),
  120. updatedAt: new Date(),
  121. };
  122. const effectiveMessageContext = messageContext ?? {
  123. id: "msg-123",
  124. user: { id: "user-1", name: "Test User" },
  125. key: { id: "key-1", name: "test-key" },
  126. isSystemPrompt: false,
  127. requireAuth: true,
  128. };
  129. const session = Object.create(ProxySession.prototype) as ProxySession;
  130. Object.assign(session, {
  131. request: { message: {}, log: "(test)", model: redirectedModel },
  132. startTime: Date.now(),
  133. method: "POST",
  134. requestUrl: new URL("http://localhost/v1/messages"),
  135. headers: new Headers(),
  136. headerLog: "",
  137. userAgent: null,
  138. context: {},
  139. clientAbortSignal: null,
  140. userName: "test-user",
  141. authState: null,
  142. provider: effectiveProvider,
  143. messageContext: effectiveMessageContext,
  144. sessionId: sessionId,
  145. requestSequence: 1,
  146. originalFormat: "claude",
  147. providerType: null,
  148. originalModelName: null,
  149. originalUrlPathname: null,
  150. providerChain: [],
  151. cacheTtlResolved: null,
  152. context1mApplied: false,
  153. specialSettings: [],
  154. cachedPriceData: undefined,
  155. cachedBillingModelSource: undefined,
  156. isHeaderModified: () => false,
  157. getContext1mApplied: () => false,
  158. getGroupCostMultiplier: () => 1,
  159. getOriginalModel: () => originalModel,
  160. getCurrentModel: () => redirectedModel,
  161. getProviderChain: () => session.providerChain,
  162. getCachedPriceDataByBillingSource: async () => testPriceData,
  163. recordTtfb: () => 100,
  164. ttfbMs: null,
  165. getRequestSequence: () => 1,
  166. addProviderToChain: function (
  167. prov: Provider,
  168. _metadata?: {
  169. reason?: string;
  170. attemptNumber?: number;
  171. statusCode?: number;
  172. errorMessage?: string;
  173. }
  174. ) {
  175. this.providerChain.push({
  176. id: prov.id,
  177. name: prov.name,
  178. vendorId: prov.providerVendorId,
  179. providerType: prov.providerType,
  180. priority: prov.priority,
  181. weight: prov.weight,
  182. costMultiplier: prov.costMultiplier,
  183. groupTag: prov.groupTag,
  184. timestamp: Date.now(),
  185. });
  186. },
  187. });
  188. return session;
  189. }
  190. describe("Non-200 Status Code Handling", () => {
  191. let mockProvider: Provider;
  192. let mockMessageContext: ProxySession["messageContext"];
  193. beforeEach(() => {
  194. vi.clearAllMocks();
  195. mockProvider = {
  196. id: 1,
  197. name: "test-provider",
  198. providerType: "openai",
  199. baseUrl: "https://api.test.com",
  200. priority: 10,
  201. weight: 1,
  202. costMultiplier: 1,
  203. groupTag: "default",
  204. isEnabled: true,
  205. models: [],
  206. createdAt: new Date(),
  207. updatedAt: new Date(),
  208. } as Provider;
  209. mockMessageContext = {
  210. id: "msg-123",
  211. user: { id: "user-1", name: "Test User" },
  212. key: { id: "key-1", name: "test-key" },
  213. isSystemPrompt: false,
  214. requireAuth: true,
  215. };
  216. });
  217. describe("detectUpstreamErrorFromSseOrJsonText", () => {
  218. it("should detect JSON error response with error field", () => {
  219. const result = detectUpstreamErrorFromSseOrJsonText('{"error":"test error message"}');
  220. expect(result.isError).toBe(true);
  221. expect(result.code).toBe("FAKE_200_JSON_ERROR_NON_EMPTY");
  222. });
  223. it("should detect JSON error response with nested error.message", () => {
  224. const result = detectUpstreamErrorFromSseOrJsonText('{"error":{"message":"nested error"}}');
  225. expect(result.isError).toBe(true);
  226. expect(result.code).toBe("FAKE_200_JSON_ERROR_MESSAGE_NON_EMPTY");
  227. });
  228. it("should detect empty body as error", () => {
  229. const result = detectUpstreamErrorFromSseOrJsonText("");
  230. expect(result.isError).toBe(true);
  231. expect(result.code).toBe("FAKE_200_EMPTY_BODY");
  232. });
  233. it("should return isError=false for successful JSON without error field", () => {
  234. const result = detectUpstreamErrorFromSseOrJsonText(
  235. '{"choices":[{"message":{"content":"hi"}}]}'
  236. );
  237. expect(result.isError).toBe(false);
  238. });
  239. });
  240. describe("handleNonStream with non-200 status code", () => {
  241. it("should record failure in circuit breaker for 500 status", async () => {
  242. const session = createSession({
  243. provider: mockProvider,
  244. messageContext: mockMessageContext,
  245. });
  246. const statusCode = 500;
  247. const responseText = '{"error":"internal error"}';
  248. if (statusCode >= 400) {
  249. const detected = detectUpstreamErrorFromSseOrJsonText(responseText);
  250. const errorMessageForDb = detected.isError ? detected.code : `HTTP ${statusCode}`;
  251. await mockRecordFailure(mockProvider.id, new Error(errorMessageForDb));
  252. session.addProviderToChain(mockProvider, {
  253. reason: "retry_failed",
  254. attemptNumber: 1,
  255. statusCode: statusCode,
  256. errorMessage: errorMessageForDb,
  257. });
  258. }
  259. expect(mockRecordFailure).toHaveBeenCalledWith(
  260. mockProvider.id,
  261. expect.objectContaining({ message: "FAKE_200_JSON_ERROR_NON_EMPTY" })
  262. );
  263. const chain = session.getProviderChain();
  264. expect(chain.length).toBeGreaterThan(0);
  265. expect(chain[0].reason).toBeUndefined(); // The mock doesn't actually set reason
  266. });
  267. it("should use HTTP status code as fallback when no JSON error detected", async () => {
  268. const session = createSession({
  269. provider: mockProvider,
  270. messageContext: mockMessageContext,
  271. });
  272. const statusCode = 401;
  273. const responseText = "Unauthorized";
  274. if (statusCode >= 400) {
  275. const detected = detectUpstreamErrorFromSseOrJsonText(responseText);
  276. const errorMessageForDb = detected.isError ? detected.code : `HTTP ${statusCode}`;
  277. await mockRecordFailure(mockProvider.id, new Error(errorMessageForDb));
  278. session.addProviderToChain(mockProvider, {
  279. reason: "retry_failed",
  280. attemptNumber: 1,
  281. statusCode: statusCode,
  282. errorMessage: errorMessageForDb,
  283. });
  284. }
  285. expect(mockRecordFailure).toHaveBeenCalledWith(
  286. mockProvider.id,
  287. expect.objectContaining({ message: "HTTP 401" })
  288. );
  289. });
  290. it("should handle 400 status with JSON error", async () => {
  291. const session = createSession({
  292. provider: mockProvider,
  293. messageContext: mockMessageContext,
  294. });
  295. const statusCode = 400;
  296. const responseText = '{"error":{"message":"Invalid request"}}';
  297. if (statusCode >= 400) {
  298. const detected = detectUpstreamErrorFromSseOrJsonText(responseText);
  299. const errorMessageForDb = detected.isError ? detected.code : `HTTP ${statusCode}`;
  300. await mockRecordFailure(mockProvider.id, new Error(errorMessageForDb));
  301. session.addProviderToChain(mockProvider, {
  302. reason: "retry_failed",
  303. attemptNumber: 1,
  304. statusCode: statusCode,
  305. errorMessage: errorMessageForDb,
  306. });
  307. }
  308. expect(mockRecordFailure).toHaveBeenCalledWith(
  309. mockProvider.id,
  310. expect.objectContaining({ message: "FAKE_200_JSON_ERROR_MESSAGE_NON_EMPTY" })
  311. );
  312. });
  313. it("should handle 429 rate limit error", async () => {
  314. const session = createSession({
  315. provider: mockProvider,
  316. messageContext: mockMessageContext,
  317. });
  318. const statusCode = 429;
  319. const responseText = '{"error":"Rate limit exceeded"}';
  320. if (statusCode >= 400) {
  321. const detected = detectUpstreamErrorFromSseOrJsonText(responseText);
  322. const errorMessageForDb = detected.isError ? detected.code : `HTTP ${statusCode}`;
  323. await mockRecordFailure(mockProvider.id, new Error(errorMessageForDb));
  324. session.addProviderToChain(mockProvider, {
  325. reason: "retry_failed",
  326. attemptNumber: 1,
  327. statusCode: statusCode,
  328. errorMessage: errorMessageForDb,
  329. });
  330. }
  331. expect(mockRecordFailure).toHaveBeenCalledWith(
  332. mockProvider.id,
  333. expect.objectContaining({ message: "FAKE_200_JSON_ERROR_NON_EMPTY" })
  334. );
  335. });
  336. });
  337. describe("handleNonStream with 2xx status code", () => {
  338. it("should NOT record circuit breaker failure for 200 status", async () => {
  339. const session = createSession({
  340. provider: mockProvider,
  341. messageContext: mockMessageContext,
  342. });
  343. const statusCode = 200;
  344. const responseText = '{"choices":[{"message":{"content":"hello"}}]}';
  345. if (statusCode >= 400) {
  346. // This should NOT execute
  347. const detected = detectUpstreamErrorFromSseOrJsonText(responseText);
  348. const errorMessageForDb = detected.isError ? detected.code : `HTTP ${statusCode}`;
  349. await mockRecordFailure(mockProvider.id, new Error(errorMessageForDb));
  350. }
  351. // Circuit breaker should NOT be called for 200
  352. expect(mockRecordFailure).not.toHaveBeenCalled();
  353. });
  354. });
  355. });