diff --git a/convex/ai/byokErrors.ts b/convex/ai/byokErrors.ts index a36ffee1..9b83bb37 100644 --- a/convex/ai/byokErrors.ts +++ b/convex/ai/byokErrors.ts @@ -50,6 +50,8 @@ export function classifyByokError(error: unknown): ByokErrorCode | null { : (error as Error & { status?: number }).status; const lower = gatherErrorText(error); + if (lower.includes("provider_response_failed")) return "byok_unknown_error"; + if (status === 401 || status === 403) return "byok_key_invalid"; if ( lower.includes("api key not valid") || diff --git a/convex/ai/resilience.ts b/convex/ai/resilience.ts index dce6bebe..9cb45025 100644 --- a/convex/ai/resilience.ts +++ b/convex/ai/resilience.ts @@ -266,7 +266,7 @@ async function attemptStream({ STREAM_OPTIONS, ); await result.text; - + if (accumulator.toRow().finishReason === "error") throw new Error("provider_response_failed"); if (budgetTrip) { await ctx.runMutation(internal.aiUsage.recordBudgetStop, { userId: userId as Id<"users">, diff --git a/convex/ai/resilienceStreamFailure.test.ts b/convex/ai/resilienceStreamFailure.test.ts new file mode 100644 index 00000000..2dcc74bf --- /dev/null +++ b/convex/ai/resilienceStreamFailure.test.ts @@ -0,0 +1,115 @@ +import type { Agent } from "@convex-dev/agent"; +import { saveMessage } from "@convex-dev/agent"; +import type { StepResult, ToolSet } from "ai"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ActionCtx } from "../_generated/server"; +import { streamWithRetry } from "./resilience"; + +const runWithPrimaryCircuitBreakerMock = vi.hoisted(() => vi.fn()); +const recordErrorMock = vi.hoisted(() => vi.fn()); + +vi.mock("@convex-dev/agent", async (importOriginal) => ({ + ...(await importOriginal()), + saveMessage: vi.fn(async () => undefined), +})); + +vi.mock("./otel", () => ({ + runInRunSpan: async ( + _metadata: unknown, + fn: (span: { runId: string; recordError: (error: string) => void }) => Promise, + ) => fn({ runId: "run-response-failed", recordError: recordErrorMock }), +})); + +vi.mock("./resilienceCircuitBreaker", () => ({ + runWithPrimaryCircuitBreaker: runWithPrimaryCircuitBreakerMock, +})); + +function responseFailedStep(): StepResult { + return { + finishReason: "error", + usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, + toolCalls: [], + toolResults: [], + response: { + id: "resp_failed", + model: { + provider: "openai.responses", + modelId: "gpt-5.4", + }, + timestamp: new Date(0), + }, + model: { + provider: "openai.responses", + modelId: "gpt-5.4", + }, + stepNumber: 0, + } as unknown as StepResult; +} + +describe("streamWithRetry provider response failures", () => { + beforeEach(() => { + vi.clearAllMocks(); + runWithPrimaryCircuitBreakerMock.mockImplementation( + async (options: { primaryAgent: Agent; runAttempt: unknown }) => { + const runAttempt = options.runAttempt as (agent: Agent) => Promise; + return await runAttempt(options.primaryAgent); + }, + ); + }); + + it("surfaces non-thrown provider finish errors as BYOK messages", async () => { + const streamText = vi.fn( + async (options: { onStepFinish: (step: StepResult) => void }) => { + options.onStepFinish(responseFailedStep()); + return { text: Promise.resolve("") }; + }, + ); + const agent = { + continueThread: vi.fn(async () => ({ thread: { streamText } })), + } as unknown as Agent; + const runQuery = vi.fn(async () => ({ + page: [{ _id: "pending-message", status: "pending" }], + })); + const runMutation = vi.fn(async () => undefined); + const runAction = vi.fn(async () => undefined); + + const accumulator = await streamWithRetry( + { runQuery, runMutation, runAction } as unknown as ActionCtx, + { + primaryAgent: agent, + fallbackAgent: agent, + primaryModelName: "gpt-5.4", + threadId: "thread-1", + userId: "user-1", + prompt: "hello", + isByok: true, + provider: "openai", + source: "chat", + environment: "prod", + }, + ); + + expect(accumulator.toRow()).toMatchObject({ + finishReason: "error", + terminalErrorClass: "byok_unknown_error", + }); + expect(runMutation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + messageId: "pending-message", + result: { status: "failed", error: "byok_unknown_error" }, + }), + ); + expect(saveMessage).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + message: expect.objectContaining({ + content: expect.stringContaining("OpenAI returned an unexpected error"), + }), + }), + ); + expect(runAction).toHaveBeenCalledTimes(1); + expect(recordErrorMock).toHaveBeenCalledWith("byok_unknown_error"); + }); +}); diff --git a/src/lib/posthogBeforeSend.test.ts b/src/lib/posthogBeforeSend.test.ts index 8d59a855..9ce62071 100644 --- a/src/lib/posthogBeforeSend.test.ts +++ b/src/lib/posthogBeforeSend.test.ts @@ -140,6 +140,32 @@ describe("shouldDropPosthogEvent", () => { expect(dropped).toBe(true); }); + it("drops Firefox NetworkError fetch failures (Convex transport offline)", () => { + const event = makeEvent({ + properties: { + $exception_values: [ + { + value: "NetworkError when attempting to fetch resource. (chatty-hawk-29.convex.cloud)", + }, + ], + }, + }); + + const dropped = shouldDropPosthogEvent(event); + + expect(dropped).toBe(true); + }); + + it("drops Chromium/Safari Failed to fetch transport failures", () => { + const event = makeEvent({ + properties: { $exception_values: [{ value: "Failed to fetch" }] }, + }); + + const dropped = shouldDropPosthogEvent(event); + + expect(dropped).toBe(true); + }); + it("keeps real errors", () => { const event = makeEvent({ properties: { $exception_message: "Cannot read properties of undefined (reading 'foo')" }, diff --git a/src/lib/posthogBeforeSend.ts b/src/lib/posthogBeforeSend.ts index f1abbddd..a9c4a20a 100644 --- a/src/lib/posthogBeforeSend.ts +++ b/src/lib/posthogBeforeSend.ts @@ -19,6 +19,14 @@ const SUPPRESSED_MESSAGE_SUBSTRINGS: readonly string[] = [ "ResizeObserver loop", "Script error.", "n.standardSelectors", + // Browser-side network failures (offline, DNS, TLS, connection drops) thrown + // by fetch() before the server is even reached. Convex transport already + // auto-reconnects and surfaces a connection indicator, so these are not + // actionable application bugs. Firefox emits "NetworkError when attempting + // to fetch resource"; Chromium-based browsers and Safari emit "Failed to + // fetch". + "NetworkError when attempting to fetch resource", + "Failed to fetch", // Already-handled BYOK / app-level codes (mirrors sentryBeforeSend.ts) "byok_key_missing", "byok_model_missing", diff --git a/src/lib/sentryBeforeSend.test.ts b/src/lib/sentryBeforeSend.test.ts index 73bb9270..e21b73f8 100644 --- a/src/lib/sentryBeforeSend.test.ts +++ b/src/lib/sentryBeforeSend.test.ts @@ -116,6 +116,16 @@ describe("shouldDropSentryEvent", () => { expect(shouldDropSentryEvent(eventWithValue(payload), hintWithError(payload))).toBe(true); }); + it("drops Firefox NetworkError fetch failures (Convex transport offline)", () => { + const payload = "NetworkError when attempting to fetch resource. (chatty-hawk-29.convex.cloud)"; + expect(shouldDropSentryEvent(eventWithValue(payload), hintWithError(payload))).toBe(true); + }); + + it("drops Chromium/Safari Failed to fetch transport failures", () => { + const payload = "Failed to fetch"; + expect(shouldDropSentryEvent(eventWithValue(payload), hintWithError(payload))).toBe(true); + }); + it("keeps real errors", () => { const event = eventWithValue("TypeError: Cannot read properties of undefined"); const hint = hintWithError("TypeError: Cannot read properties of undefined"); diff --git a/src/lib/sentryBeforeSend.ts b/src/lib/sentryBeforeSend.ts index 60dfe399..7421cdcd 100644 --- a/src/lib/sentryBeforeSend.ts +++ b/src/lib/sentryBeforeSend.ts @@ -18,6 +18,14 @@ const SUPPRESSED_MESSAGE_SUBSTRINGS: readonly string[] = [ "ResizeObserver loop", "Script error.", "n.standardSelectors", + // Browser-side network failures (offline, DNS, TLS, connection drops) thrown + // by fetch() before the server is even reached. Convex transport already + // auto-reconnects and surfaces a connection indicator, so these are not + // actionable application bugs. Firefox emits "NetworkError when attempting + // to fetch resource"; Chromium-based browsers and Safari emit "Failed to + // fetch". + "NetworkError when attempting to fetch resource", + "Failed to fetch", // Already-handled BYOK / app-level codes "byok_key_missing", "byok_model_missing",