Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions convex/ai/byokErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") ||
Expand Down
2 changes: 1 addition & 1 deletion convex/ai/resilience.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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">,
Expand Down
115 changes: 115 additions & 0 deletions convex/ai/resilienceStreamFailure.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("@convex-dev/agent")>()),
saveMessage: vi.fn(async () => undefined),
}));

vi.mock("./otel", () => ({
runInRunSpan: async (
_metadata: unknown,
fn: (span: { runId: string; recordError: (error: string) => void }) => Promise<unknown>,
) => fn({ runId: "run-response-failed", recordError: recordErrorMock }),
}));

vi.mock("./resilienceCircuitBreaker", () => ({
runWithPrimaryCircuitBreaker: runWithPrimaryCircuitBreakerMock,
}));

function responseFailedStep(): StepResult<ToolSet> {
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<ToolSet>;
}

describe("streamWithRetry provider response failures", () => {
beforeEach(() => {
vi.clearAllMocks();
runWithPrimaryCircuitBreakerMock.mockImplementation(
async (options: { primaryAgent: Agent; runAttempt: unknown }) => {
const runAttempt = options.runAttempt as (agent: Agent) => Promise<unknown>;
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<ToolSet>) => 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");
});
});
26 changes: 26 additions & 0 deletions src/lib/posthogBeforeSend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')" },
Expand Down
8 changes: 8 additions & 0 deletions src/lib/posthogBeforeSend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Scope fetch-noise suppression to Convex transport

Avoid dropping every "Failed to fetch" / Firefox network error globally: shouldDropPosthogEvent and shouldDropSentryEvent apply these substrings to all client exceptions, so any real outage (bad API host, CORS/TLS misconfig, backend unreachability) will now be silently filtered out of both PostHog and Sentry instead of being observable. Please gate this suppression to known Convex transport contexts (for example by stack/origin/URL metadata) rather than a blanket message match.

Useful? React with 👍 / 👎.

// Already-handled BYOK / app-level codes (mirrors sentryBeforeSend.ts)
"byok_key_missing",
"byok_model_missing",
Expand Down
10 changes: 10 additions & 0 deletions src/lib/sentryBeforeSend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
8 changes: 8 additions & 0 deletions src/lib/sentryBeforeSend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading