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')