From 08b58555fda6561c3963122dec48f4f0c38ae258 Mon Sep 17 00:00:00 2001 From: "posthog-eu[bot]" <226701856+posthog-eu[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:43:47 +0000 Subject: [PATCH] fix(frontend): handle transient network failures on downloadUrl path A transient browser network drop on the bundle page's open/test button threw out of `downloadUrl` and escaped unhandled, so the user got no download and no toast. The same family of fetch TypeErrors (Chrome "Failed to fetch", WebKit "Load failed", Firefox "NetworkError when attempting to fetch resource.") also minted a fresh error-tracking issue per browser dialect. - Rethrow the original `TypeError` untouched in `downloadUrl` (wrap non-TypeError causes with `cause`) so transient-network detection can recognise the message. - Catch the `downloadUrl` call in `openVersion` so a failure lands in the existing `cannot-get-the-test-` toast instead of dead-ending. - Await `openVersion` at the bundle-page call site so the rejection is handled. - Suppress the transient browser network family in `before_send` via anchored patterns that don't swallow richer messages like "Failed to fetch org insights". Generated-By: PostHog Code Task-Id: 03e5605b-c50f-4db4-8829-b6d17c074e2d --- src/pages/app/[app].bundle.[bundle].vue | 2 +- src/services/staleAssetErrors.ts | 27 ++++++++++++++++++-- src/services/supabase.ts | 8 +++++- src/services/versions.ts | 17 ++++++++++--- tests/stale-asset-errors.unit.test.ts | 34 ++++++++++++++++++++++++- 5 files changed, 80 insertions(+), 8 deletions(-) diff --git a/src/pages/app/[app].bundle.[bundle].vue b/src/pages/app/[app].bundle.[bundle].vue index c62412d94b..493f140377 100644 --- a/src/pages/app/[app].bundle.[bundle].vue +++ b/src/pages/app/[app].bundle.[bundle].vue @@ -428,7 +428,7 @@ async function downloadNow() { }) await dialogStore.onDialogDismiss() } - openVersion(version.value) + await openVersion(version.value) } async function openDownload() { diff --git a/src/services/staleAssetErrors.ts b/src/services/staleAssetErrors.ts index d54803c172..59122a9837 100644 --- a/src/services/staleAssetErrors.ts +++ b/src/services/staleAssetErrors.ts @@ -16,6 +16,16 @@ const KNOWN_CRAWLER_ERROR_PATTERNS = [ /Object Not Found Matching Id:\d+(?:,\s*MethodName:[^,]+,\s*ParamCount:\d+)?/i, ] +// Transient browser network drops surface as a `fetch` TypeError whose exact +// wording differs per engine. Anchored so only the standalone browser message +// (optionally wrapped with a `: ` prefix, e.g. `downloadUrl error: …`) +// is suppressed — not richer messages like "Failed to fetch organization insights". +const TRANSIENT_NETWORK_ERROR_PATTERNS = [ + /^(?:.*: )?Failed to fetch$/i, + /^(?:.*: )?Load failed$/i, + /^(?:.*: )?NetworkError when attempting to fetch resource\.?$/i, +] + export function isStaleAssetErrorMessage(message: string | undefined): boolean { if (!message) return false @@ -30,6 +40,13 @@ export function isKnownCrawlerNoiseErrorMessage(message: string | undefined): bo return KNOWN_CRAWLER_ERROR_PATTERNS.some(pattern => pattern.test(message)) } +export function isTransientNetworkErrorMessage(message: string | undefined): boolean { + if (!message) + return false + + return TRANSIENT_NETWORK_ERROR_PATTERNS.some(pattern => pattern.test(message)) +} + interface PostHogExceptionLike { value?: unknown $exception_value?: unknown @@ -49,9 +66,15 @@ export function shouldSuppressPostHogExceptionEvent(event: PostHogEventLike): bo const exception = event.properties?.$exception_list?.[0] const exceptionValue = getErrorMessage(exception?.value) ?? getErrorMessage(exception?.$exception_value) - if (isStaleAssetErrorMessage(exceptionValue) || isKnownCrawlerNoiseErrorMessage(exceptionValue)) + if (isSuppressibleNoiseErrorMessage(exceptionValue)) return true const fallbackValue = getErrorMessage(event.properties?.$exception_values?.[0]) - return isStaleAssetErrorMessage(fallbackValue) || isKnownCrawlerNoiseErrorMessage(fallbackValue) + return isSuppressibleNoiseErrorMessage(fallbackValue) +} + +function isSuppressibleNoiseErrorMessage(message: string | undefined): boolean { + return isStaleAssetErrorMessage(message) + || isKnownCrawlerNoiseErrorMessage(message) + || isTransientNetworkErrorMessage(message) } diff --git a/src/services/supabase.ts b/src/services/supabase.ts index 775495f486..f08ab8bede 100644 --- a/src/services/supabase.ts +++ b/src/services/supabase.ts @@ -295,7 +295,13 @@ export async function downloadUrl(provider: string, userId: string, appId: strin return res.url } catch (e) { - throw new Error(`downloadUrl error: ${e instanceof Error ? e.message : String(e)}`) + // A transient browser network drop surfaces as a `TypeError` whose wording + // differs per engine (Chrome "Failed to fetch", WebKit "Load failed", + // Firefox "NetworkError when attempting to fetch resource."). Rethrow it + // untouched so transient-network detection can still recognise the message. + if (e instanceof TypeError) + throw e + throw new Error(`downloadUrl error: ${e instanceof Error ? e.message : String(e)}`, { cause: e }) } } diff --git a/src/services/versions.ts b/src/services/versions.ts index 3bed6f3b60..977b97258b 100644 --- a/src/services/versions.ts +++ b/src/services/versions.ts @@ -63,10 +63,21 @@ export async function openVersion(app: Database['public']['Tables']['app_version const { t } = i18n.global let signedURL - if (app.r2_path) - signedURL = await downloadUrl(app.storage_provider, app.user_id ?? '', app.app_id, app.id) - else + if (app.r2_path) { + try { + signedURL = await downloadUrl(app.storage_provider, app.user_id ?? '', app.app_id, app.id) + } + catch (error) { + // Transient network failures throw out of `downloadUrl`; surface them in + // the same toast used for the empty-URL case instead of dead-ending. + console.error('Error', error) + toast.error(t('cannot-get-the-test-')) + return + } + } + else { signedURL = app.external_url + } if (!signedURL) { toast.error(t('cannot-get-the-test-')) diff --git a/tests/stale-asset-errors.unit.test.ts b/tests/stale-asset-errors.unit.test.ts index 441595f1ae..218d1d1055 100644 --- a/tests/stale-asset-errors.unit.test.ts +++ b/tests/stale-asset-errors.unit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { getErrorMessage, isKnownCrawlerNoiseErrorMessage, isStaleAssetErrorMessage, shouldSuppressPostHogExceptionEvent } from '../src/services/staleAssetErrors' +import { getErrorMessage, isKnownCrawlerNoiseErrorMessage, isStaleAssetErrorMessage, isTransientNetworkErrorMessage, shouldSuppressPostHogExceptionEvent } from '../src/services/staleAssetErrors' describe('stale asset error helpers', () => { it('matches the stale asset errors currently seen in PostHog', () => { @@ -25,6 +25,38 @@ describe('stale asset error helpers', () => { expect(isKnownCrawlerNoiseErrorMessage('Cannot read properties of null (reading \'save\')')).toBe(false) }) + it('matches the transient browser network failures seen across engines', () => { + expect(isTransientNetworkErrorMessage('Failed to fetch')).toBe(true) + expect(isTransientNetworkErrorMessage('Load failed')).toBe(true) + expect(isTransientNetworkErrorMessage('NetworkError when attempting to fetch resource.')).toBe(true) + // Wrapped by downloadUrl's non-TypeError fallback path + expect(isTransientNetworkErrorMessage('downloadUrl error: NetworkError when attempting to fetch resource.')).toBe(true) + expect(isTransientNetworkErrorMessage('downloadUrl error: Failed to fetch')).toBe(true) + }) + + it('does not match richer messages that merely start with the same words', () => { + expect(isTransientNetworkErrorMessage('Failed to fetch organization insights')).toBe(false) + expect(isTransientNetworkErrorMessage('Failed to fetch dynamically imported module: https://console.capgo.app/assets/dashboard.js')).toBe(false) + expect(isTransientNetworkErrorMessage('downloadUrl error: HTTP 500')).toBe(false) + expect(isTransientNetworkErrorMessage(undefined)).toBe(false) + }) + + it('suppresses transient browser network exception events in PostHog', () => { + expect(shouldSuppressPostHogExceptionEvent({ + event: '$exception', + properties: { + $exception_list: [{ value: 'Failed to fetch' }], + }, + })).toBe(true) + + expect(shouldSuppressPostHogExceptionEvent({ + event: '$exception', + properties: { + $exception_values: ['downloadUrl error: NetworkError when attempting to fetch resource.'], + }, + })).toBe(true) + }) + it('extracts useful messages from arbitrary rejection values', () => { expect(getErrorMessage(new Error('Importing a module script failed.'))).toBe('Importing a module script failed.') expect(getErrorMessage({ message: 'Unable to preload CSS for /assets/main.css' })).toBe('Unable to preload CSS for /assets/main.css')