response-handler-non200.test.ts 12 KB

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