From 473c2d0777eadc7b71ac48aa6df4326aaec0c704 Mon Sep 17 00:00:00 2001 From: "ashlee@vellum.ai" Date: Wed, 3 Jun 2026 00:09:14 +0000 Subject: [PATCH] fix(web): extend bestEffort to conversation history and settings daemon calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes LUM-2199 - Add bestEffort: true to captureError in use-conversation-history.ts (WEB-7: 77 events from 503 'still starting up' during pagination) - Add bestEffort: true to captureError in web-search-card.tsx (WEB-2H: raw {detail} errors from HeyAPI throwOnError) - Extend isExpectedDaemonTransientError() to detect raw {detail: string} objects thrown by HeyAPI's throwOnError — not just ApiError instances. HeyAPI throws the parsed JSON response body verbatim, so the function now matches known daemon transient detail substrings (still starting up, organization-id header, authentication credentials, bad gateway). Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../chat/hooks/use-conversation-history.ts | 1 + .../domains/settings/ai/web-search-card.tsx | 2 +- apps/web/src/lib/sentry/capture-error.test.ts | 97 ++++++++++++++++++- apps/web/src/lib/sentry/capture-error.ts | 55 ++++++++--- 4 files changed, 141 insertions(+), 14 deletions(-) diff --git a/apps/web/src/domains/chat/hooks/use-conversation-history.ts b/apps/web/src/domains/chat/hooks/use-conversation-history.ts index 1f6ecf74278..5f4139a2e1b 100644 --- a/apps/web/src/domains/chat/hooks/use-conversation-history.ts +++ b/apps/web/src/domains/chat/hooks/use-conversation-history.ts @@ -310,6 +310,7 @@ export function useConversationHistory({ context: isOlderPageError ? "conversation_history_older_page" : "conversation_history_initial", + bestEffort: true, }); if (!isOlderPageError) { diff --git a/apps/web/src/domains/settings/ai/web-search-card.tsx b/apps/web/src/domains/settings/ai/web-search-card.tsx index f2767a7104c..b87195ff162 100644 --- a/apps/web/src/domains/settings/ai/web-search-card.tsx +++ b/apps/web/src/domains/settings/ai/web-search-card.tsx @@ -125,7 +125,7 @@ export function WebSearchCard() { } catch (error) { if (cancelled) return; setWebSearchHasStoredKey(false); - captureError(error, { context: "settings-ai-web-search-read-secret" }); + captureError(error, { context: "settings-ai-web-search-read-secret", bestEffort: true }); } })(); diff --git a/apps/web/src/lib/sentry/capture-error.test.ts b/apps/web/src/lib/sentry/capture-error.test.ts index d729c03f2de..4c5295953d6 100644 --- a/apps/web/src/lib/sentry/capture-error.test.ts +++ b/apps/web/src/lib/sentry/capture-error.test.ts @@ -186,14 +186,109 @@ describe("isExpectedDaemonTransientError", () => { ).toBe(false); }); - test("returns false for non-ApiError instances", () => { + test("returns false for non-ApiError Error instances", () => { expect(isExpectedDaemonTransientError(new Error("random error"))).toBe( false, ); expect( isExpectedDaemonTransientError(new TypeError("Failed to fetch")), ).toBe(false); + }); + + test("returns false for primitives and null", () => { expect(isExpectedDaemonTransientError("string error")).toBe(false); expect(isExpectedDaemonTransientError(null)).toBe(false); + expect(isExpectedDaemonTransientError(undefined)).toBe(false); + expect(isExpectedDaemonTransientError(42)).toBe(false); + }); + + // HeyAPI throwOnError throws raw JSON response bodies ({detail: "..."}) + // without wrapping in ApiError. These tests verify detection of the raw + // Django REST framework error shape. + test("returns true for raw {detail} with 503 startup message", () => { + expect( + isExpectedDaemonTransientError({ + detail: "Your assistant is still starting up. Please try again in a moment.", + }), + ).toBe(true); + }); + + test("returns true for raw {detail} with org-header message", () => { + expect( + isExpectedDaemonTransientError({ + detail: "Vellum-Organization-Id header is required.", + }), + ).toBe(true); + }); + + test("returns true for raw {detail} with 401 auth message", () => { + expect( + isExpectedDaemonTransientError({ + detail: "Authentication credentials were not provided.", + }), + ).toBe(true); + }); + + test("returns true for raw {detail} with bad gateway message", () => { + expect( + isExpectedDaemonTransientError({ detail: "Bad gateway" }), + ).toBe(true); + expect( + isExpectedDaemonTransientError({ detail: "Bad Gateway" }), + ).toBe(true); + }); + + test("returns false for raw {detail} with unknown message", () => { + expect( + isExpectedDaemonTransientError({ detail: "Internal Server Error" }), + ).toBe(false); + expect( + isExpectedDaemonTransientError({ detail: "Permission denied." }), + ).toBe(false); + }); + + test("returns false for raw object without detail field", () => { + expect( + isExpectedDaemonTransientError({ message: "something" }), + ).toBe(false); + expect(isExpectedDaemonTransientError({})).toBe(false); + }); + + test("returns false for raw {detail} with non-string value", () => { + expect( + isExpectedDaemonTransientError({ detail: 503 }), + ).toBe(false); + expect( + isExpectedDaemonTransientError({ detail: ["error1", "error2"] }), + ).toBe(false); + }); +}); + +describe("captureError with bestEffort and raw HeyAPI errors", () => { + test("silently drops raw {detail} daemon transient errors with bestEffort", () => { + captureExceptionMock.mockClear(); + captureError( + { detail: "Your assistant is still starting up. Please try again in a moment." }, + { context: "test-ctx", bestEffort: true }, + ); + expect(captureExceptionMock).not.toHaveBeenCalled(); + }); + + test("reports raw {detail} daemon transient errors without bestEffort", () => { + captureExceptionMock.mockClear(); + captureError( + { detail: "Your assistant is still starting up." }, + { context: "test-ctx" }, + ); + expect(captureExceptionMock).toHaveBeenCalledTimes(1); + }); + + test("reports raw {detail} with unknown message even with bestEffort", () => { + captureExceptionMock.mockClear(); + captureError( + { detail: "Internal Server Error" }, + { context: "test-ctx", bestEffort: true }, + ); + expect(captureExceptionMock).toHaveBeenCalledTimes(1); }); }); diff --git a/apps/web/src/lib/sentry/capture-error.ts b/apps/web/src/lib/sentry/capture-error.ts index dbdf24c44fe..4ecefcc8344 100644 --- a/apps/web/src/lib/sentry/capture-error.ts +++ b/apps/web/src/lib/sentry/capture-error.ts @@ -46,6 +46,21 @@ export function normalizeToError(value: unknown): Error { return new Error(String(value)); } +/** + * Known daemon transient detail messages. HeyAPI's `throwOnError` + * throws the raw JSON body (`{detail: "..."}`) without the HTTP status + * code, so we fall back to substring matching on the `detail` value. + * + * Each entry is a lowercase substring that uniquely identifies one of + * the daemon's expected transient responses. + */ +const DAEMON_TRANSIENT_DETAILS: readonly string[] = [ + "still starting up", // 503 — daemon booting + "organization-id header", // 400 — org store not hydrated + "authentication credentials", // 401 — auth session race + "bad gateway", // 502 — reverse proxy +]; + /** * Detects expected transient HTTP errors from daemon API calls that * occur during normal startup sequences and auth-session hydration. @@ -59,21 +74,37 @@ export function normalizeToError(value: unknown): Error { * - **400 with org-header message** — Org store has not hydrated yet; * the `Vellum-Organization-Id` header interceptor read `null` * - * Only `ApiError` instances are matched. Other error types (TypeError, - * generic Error, plain objects) pass through — they represent network - * failures (handled by `isTransientNetworkError`) or application bugs. + * Matches both structured `ApiError` instances (from manual + * construction with `throwOnError: false`) and raw `{detail: string}` + * objects thrown by HeyAPI's `throwOnError: true`. + * + * Reference: https://heyapi.dev/openapi-ts/clients/fetch#throwing-errors */ export function isExpectedDaemonTransientError(error: unknown): boolean { - if (!(error instanceof ApiError)) return false; - if (error.status === 503) return true; - if (error.status === 502) return true; - if (error.status === 401) return true; - if ( - error.status === 400 && - error.message.includes("Organization-Id header") - ) { - return true; + if (error instanceof ApiError) { + if (error.status === 503) return true; + if (error.status === 502) return true; + if (error.status === 401) return true; + if ( + error.status === 400 && + error.message.includes("Organization-Id header") + ) { + return true; + } + return false; + } + + // HeyAPI throwOnError throws the raw JSON response body — typically + // {detail: "message"} for Django REST framework errors — without the + // HTTP status code. Match on known daemon transient detail strings. + if (typeof error === "object" && error !== null && "detail" in error) { + const detail = (error as Record).detail; + if (typeof detail === "string") { + const lower = detail.toLowerCase(); + return DAEMON_TRANSIENT_DETAILS.some((pattern) => lower.includes(pattern)); + } } + return false; }