| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411 |
- /**
- * Tests for non-200 status code handling in response-handler.ts
- *
- * Verifies that:
- * - Non-200 responses trigger circuit breaker recording
- * - JSON error responses are parsed correctly
- * - Provider chain is updated with error info
- * - Error messages are captured for logging
- */
- import { beforeEach, describe, expect, it, vi } from "vitest";
- import type { ModelPriceData } from "@/types/model-price";
- import type { Provider } from "@/types/provider";
- import { ProxySession } from "@/app/v1/_lib/proxy/session";
- import { detectUpstreamErrorFromSseOrJsonText } from "@/lib/utils/upstream-error-detection";
- // Track async tasks for draining
- const asyncTasks: Promise<void>[] = [];
- vi.mock("@/lib/async-task-manager", () => ({
- AsyncTaskManager: {
- register: (_taskId: string, promise: Promise<void>) => {
- asyncTasks.push(promise);
- return new AbortController();
- },
- cleanup: () => {},
- cancel: () => {},
- },
- }));
- vi.mock("@/lib/logger", () => ({
- logger: {
- debug: () => {},
- info: () => {},
- warn: () => {},
- error: () => {},
- trace: () => {},
- },
- }));
- vi.mock("@/lib/price-sync/cloud-price-updater", () => ({
- requestCloudPriceTableSync: () => {},
- }));
- vi.mock("@/repository/model-price", () => ({
- findLatestPriceByModel: vi.fn(),
- }));
- vi.mock("@/repository/system-config", () => ({
- getSystemSettings: vi.fn(),
- }));
- vi.mock("@/repository/message", () => ({
- updateMessageRequestCost: vi.fn(),
- updateMessageRequestDetails: vi.fn(),
- updateMessageRequestDuration: vi.fn(),
- }));
- vi.mock("@/lib/session-manager", () => ({
- SessionManager: {
- updateSessionUsage: vi.fn(),
- storeSessionResponse: vi.fn(),
- extractCodexPromptCacheKey: vi.fn(),
- updateSessionWithCodexCacheKey: vi.fn(),
- },
- }));
- vi.mock("@/lib/rate-limit", () => ({
- RateLimitService: {
- trackCost: vi.fn(),
- trackUserDailyCost: vi.fn(),
- decrementLeaseBudget: vi.fn(),
- },
- }));
- vi.mock("@/lib/session-tracker", () => ({
- SessionTracker: {
- refreshSession: vi.fn(),
- },
- }));
- vi.mock("@/lib/proxy-status-tracker", () => ({
- ProxyStatusTracker: {
- getInstance: () => ({
- endRequest: () => {},
- }),
- },
- }));
- // Mock circuit breaker before import
- const mockRecordFailure = vi.fn();
- vi.mock("@/lib/circuit-breaker", () => ({
- recordFailure: mockRecordFailure,
- }));
- vi.mock("@/lib/endpoint-circuit-breaker", () => ({
- recordEndpointFailure: vi.fn(),
- }));
- // Test price data
- const testPriceData: ModelPriceData = {
- input_cost_per_token: 0.000003,
- output_cost_per_token: 0.000015,
- };
- function createSession(opts: {
- originalModel?: string;
- redirectedModel?: string;
- sessionId?: string | null;
- messageId?: string;
- provider?: Provider;
- messageContext?: ProxySession["messageContext"];
- }): ProxySession {
- const {
- originalModel = "test-model",
- redirectedModel = "test-model",
- sessionId = null,
- messageId = "msg-123",
- provider,
- messageContext,
- } = opts;
- // Use defaults if not provided
- const effectiveProvider = provider ?? {
- id: 1,
- name: "test-provider",
- providerType: "openai" as const,
- baseUrl: "https://api.test.com",
- priority: 10,
- weight: 1,
- costMultiplier: 1,
- groupTag: "default",
- isEnabled: true,
- models: [],
- createdAt: new Date(),
- updatedAt: new Date(),
- };
- const effectiveMessageContext = messageContext ?? {
- id: "msg-123",
- user: { id: "user-1", name: "Test User" },
- key: { id: "key-1", name: "test-key" },
- isSystemPrompt: false,
- requireAuth: true,
- };
- const session = Object.create(ProxySession.prototype) as ProxySession;
- Object.assign(session, {
- request: { message: {}, log: "(test)", model: redirectedModel },
- startTime: Date.now(),
- method: "POST",
- requestUrl: new URL("http://localhost/v1/messages"),
- headers: new Headers(),
- headerLog: "",
- userAgent: null,
- context: {},
- clientAbortSignal: null,
- userName: "test-user",
- authState: null,
- provider: effectiveProvider,
- messageContext: effectiveMessageContext,
- sessionId: sessionId,
- requestSequence: 1,
- originalFormat: "claude",
- providerType: null,
- originalModelName: null,
- originalUrlPathname: null,
- providerChain: [],
- cacheTtlResolved: null,
- context1mApplied: false,
- specialSettings: [],
- cachedPriceData: undefined,
- cachedBillingModelSource: undefined,
- isHeaderModified: () => false,
- getContext1mApplied: () => false,
- getOriginalModel: () => originalModel,
- getCurrentModel: () => redirectedModel,
- getProviderChain: () => session.providerChain,
- getCachedPriceDataByBillingSource: async () => testPriceData,
- recordTtfb: () => 100,
- ttfbMs: null,
- getRequestSequence: () => 1,
- addProviderToChain: function (
- prov: Provider,
- _metadata?: {
- reason?: string;
- attemptNumber?: number;
- statusCode?: number;
- errorMessage?: string;
- }
- ) {
- this.providerChain.push({
- id: prov.id,
- name: prov.name,
- vendorId: prov.providerVendorId,
- providerType: prov.providerType,
- priority: prov.priority,
- weight: prov.weight,
- costMultiplier: prov.costMultiplier,
- groupTag: prov.groupTag,
- timestamp: Date.now(),
- });
- },
- });
- return session;
- }
- describe("Non-200 Status Code Handling", () => {
- let mockProvider: Provider;
- let mockMessageContext: ProxySession["messageContext"];
- beforeEach(() => {
- vi.clearAllMocks();
- mockProvider = {
- id: 1,
- name: "test-provider",
- providerType: "openai",
- baseUrl: "https://api.test.com",
- priority: 10,
- weight: 1,
- costMultiplier: 1,
- groupTag: "default",
- isEnabled: true,
- models: [],
- createdAt: new Date(),
- updatedAt: new Date(),
- } as Provider;
- mockMessageContext = {
- id: "msg-123",
- user: { id: "user-1", name: "Test User" },
- key: { id: "key-1", name: "test-key" },
- isSystemPrompt: false,
- requireAuth: true,
- };
- });
- describe("detectUpstreamErrorFromSseOrJsonText", () => {
- it("should detect JSON error response with error field", () => {
- const result = detectUpstreamErrorFromSseOrJsonText('{"error":"test error message"}');
- expect(result.isError).toBe(true);
- expect(result.code).toBe("FAKE_200_JSON_ERROR_NON_EMPTY");
- });
- it("should detect JSON error response with nested error.message", () => {
- const result = detectUpstreamErrorFromSseOrJsonText('{"error":{"message":"nested error"}}');
- expect(result.isError).toBe(true);
- expect(result.code).toBe("FAKE_200_JSON_ERROR_MESSAGE_NON_EMPTY");
- });
- it("should detect empty body as error", () => {
- const result = detectUpstreamErrorFromSseOrJsonText("");
- expect(result.isError).toBe(true);
- expect(result.code).toBe("FAKE_200_EMPTY_BODY");
- });
- it("should return isError=false for successful JSON without error field", () => {
- const result = detectUpstreamErrorFromSseOrJsonText(
- '{"choices":[{"message":{"content":"hi"}}]}'
- );
- expect(result.isError).toBe(false);
- });
- });
- describe("handleNonStream with non-200 status code", () => {
- it("should record failure in circuit breaker for 500 status", async () => {
- const session = createSession({
- provider: mockProvider,
- messageContext: mockMessageContext,
- });
- const statusCode = 500;
- const responseText = '{"error":"internal error"}';
- if (statusCode >= 400) {
- const detected = detectUpstreamErrorFromSseOrJsonText(responseText);
- const errorMessageForDb = detected.isError ? detected.code : `HTTP ${statusCode}`;
- await mockRecordFailure(mockProvider.id, new Error(errorMessageForDb));
- session.addProviderToChain(mockProvider, {
- reason: "retry_failed",
- attemptNumber: 1,
- statusCode: statusCode,
- errorMessage: errorMessageForDb,
- });
- }
- expect(mockRecordFailure).toHaveBeenCalledWith(
- mockProvider.id,
- expect.objectContaining({ message: "FAKE_200_JSON_ERROR_NON_EMPTY" })
- );
- const chain = session.getProviderChain();
- expect(chain.length).toBeGreaterThan(0);
- expect(chain[0].reason).toBeUndefined(); // The mock doesn't actually set reason
- });
- it("should use HTTP status code as fallback when no JSON error detected", async () => {
- const session = createSession({
- provider: mockProvider,
- messageContext: mockMessageContext,
- });
- const statusCode = 401;
- const responseText = "Unauthorized";
- if (statusCode >= 400) {
- const detected = detectUpstreamErrorFromSseOrJsonText(responseText);
- const errorMessageForDb = detected.isError ? detected.code : `HTTP ${statusCode}`;
- await mockRecordFailure(mockProvider.id, new Error(errorMessageForDb));
- session.addProviderToChain(mockProvider, {
- reason: "retry_failed",
- attemptNumber: 1,
- statusCode: statusCode,
- errorMessage: errorMessageForDb,
- });
- }
- expect(mockRecordFailure).toHaveBeenCalledWith(
- mockProvider.id,
- expect.objectContaining({ message: "HTTP 401" })
- );
- });
- it("should handle 400 status with JSON error", async () => {
- const session = createSession({
- provider: mockProvider,
- messageContext: mockMessageContext,
- });
- const statusCode = 400;
- const responseText = '{"error":{"message":"Invalid request"}}';
- if (statusCode >= 400) {
- const detected = detectUpstreamErrorFromSseOrJsonText(responseText);
- const errorMessageForDb = detected.isError ? detected.code : `HTTP ${statusCode}`;
- await mockRecordFailure(mockProvider.id, new Error(errorMessageForDb));
- session.addProviderToChain(mockProvider, {
- reason: "retry_failed",
- attemptNumber: 1,
- statusCode: statusCode,
- errorMessage: errorMessageForDb,
- });
- }
- expect(mockRecordFailure).toHaveBeenCalledWith(
- mockProvider.id,
- expect.objectContaining({ message: "FAKE_200_JSON_ERROR_MESSAGE_NON_EMPTY" })
- );
- });
- it("should handle 429 rate limit error", async () => {
- const session = createSession({
- provider: mockProvider,
- messageContext: mockMessageContext,
- });
- const statusCode = 429;
- const responseText = '{"error":"Rate limit exceeded"}';
- if (statusCode >= 400) {
- const detected = detectUpstreamErrorFromSseOrJsonText(responseText);
- const errorMessageForDb = detected.isError ? detected.code : `HTTP ${statusCode}`;
- await mockRecordFailure(mockProvider.id, new Error(errorMessageForDb));
- session.addProviderToChain(mockProvider, {
- reason: "retry_failed",
- attemptNumber: 1,
- statusCode: statusCode,
- errorMessage: errorMessageForDb,
- });
- }
- expect(mockRecordFailure).toHaveBeenCalledWith(
- mockProvider.id,
- expect.objectContaining({ message: "FAKE_200_JSON_ERROR_NON_EMPTY" })
- );
- });
- });
- describe("handleNonStream with 2xx status code", () => {
- it("should NOT record circuit breaker failure for 200 status", async () => {
- const session = createSession({
- provider: mockProvider,
- messageContext: mockMessageContext,
- });
- const statusCode = 200;
- const responseText = '{"choices":[{"message":{"content":"hello"}}]}';
- if (statusCode >= 400) {
- // This should NOT execute
- const detected = detectUpstreamErrorFromSseOrJsonText(responseText);
- const errorMessageForDb = detected.isError ? detected.code : `HTTP ${statusCode}`;
- await mockRecordFailure(mockProvider.id, new Error(errorMessageForDb));
- }
- // Circuit breaker should NOT be called for 200
- expect(mockRecordFailure).not.toHaveBeenCalled();
- });
- });
- });
|