diff --git a/apps/mobile/src/lib/telemetry/network-errors.test.ts b/apps/mobile/src/lib/telemetry/network-errors.test.ts index 486695f828..01c98f3eba 100644 --- a/apps/mobile/src/lib/telemetry/network-errors.test.ts +++ b/apps/mobile/src/lib/telemetry/network-errors.test.ts @@ -181,6 +181,31 @@ describe('isAbortError', () => { expect(isAbortError(new Error('nope'))).toBe(false); expect(isAbortError(undefined)).toBe(false); }); + + it('is true for an Expo FetchError cancellation by message', () => { + const error = new Error( + 'fetch failed: FetchRequestCanceledException: Fetch request has been canceled' + ); + expect(error.name).toBe('Error'); + expect(isAbortError(error)).toBe(true); + }); + + it('is true for an expo-modules-core CodedError cancellation by code', () => { + const error = { + code: 'FetchRequestCanceledException', + message: 'Fetch request has been canceled', + }; + expect(isAbortError(error)).toBe(true); + }); + + it('is true for a raw FetchRequestCanceledException by name', () => { + expect(isAbortError(namedError('FetchRequestCanceledException'))).toBe(true); + }); + + it('is true when the cancellation is nested one level under cause', () => { + const cause = { code: 'FetchRequestCanceledException' }; + expect(isAbortError({ message: 'wrapped', cause })).toBe(true); + }); }); describe('createNetworkErrorFetch', () => { @@ -220,6 +245,17 @@ describe('createNetworkErrorFetch', () => { expect(events).toHaveLength(0); }); + it('(b2) does not report an Expo FetchRequestCanceledException and re-throws it', async () => { + const cancel = new Error( + 'fetch failed: FetchRequestCanceledException: Fetch request has been canceled' + ); + const wrapped = createNetworkErrorFetch(rejectingFetch(cancel), { source: 'trpc' }); + + await expect(wrapped('https://example.com/api/trpc/session.list')).rejects.toBe(cancel); + + expect(events).toHaveLength(0); + }); + it('(c) marks a RequestDeadlineError as a timeout', async () => { const deadline = namedError('RequestDeadlineError', 'Request timed out after 15000ms'); const wrapped = createNetworkErrorFetch(rejectingFetch(deadline), { source: 'trpc' }); diff --git a/apps/mobile/src/lib/telemetry/network-errors.ts b/apps/mobile/src/lib/telemetry/network-errors.ts index 0809d9abc8..d0275a531d 100644 --- a/apps/mobile/src/lib/telemetry/network-errors.ts +++ b/apps/mobile/src/lib/telemetry/network-errors.ts @@ -74,6 +74,17 @@ export type TrpcResponseError = z.infer; // key on it so a structured error never needs an `instanceof` across bundles. const NamedErrorSchema = z.looseObject({ name: z.string() }); +// Expo SDK 57's fetch rejects a canceled request with a +// `FetchRequestCanceledException`: a raw native error carries it as `name`, an +// expo-modules-core `CodedError` as `code`, and the `FetchError` wrapper (whose +// `name` is `Error`) embeds it in `message`. +const ErrorCodeSchema = z.looseObject({ code: z.string() }); +const ErrorMessageSchema = z.looseObject({ message: z.string() }); +const ErrorCauseSchema = z.looseObject({ cause: z.unknown() }); + +const EXPO_CANCEL_NAME = 'FetchRequestCanceledException'; +const ABORT_ERROR_NAME = 'AbortError'; + const TRPC_PATH = '/api/trpc/'; const HTTP_URL_PATTERN = /^https?:\/\//iu; @@ -162,9 +173,40 @@ function errorPropertyOf(value: unknown): TrpcResponseError | undefined { return parsed.success ? parsed.data.error : undefined; } -/** True when the value is an abort (`AbortError` / `DOMException`). */ +/** + * True when the value is an abort: an `AbortError` / `DOMException` or an Expo + * `FetchRequestCanceledException`. The Expo cancellation is checked on the + * error itself and one level of `cause`, since the SDK wraps it. Total: an + * unrecognized value yields `false` and never throws. + */ export function isAbortError(error: unknown): boolean { - return hasErrorName(error, 'AbortError'); + try { + if (hasErrorName(error, ABORT_ERROR_NAME) || isExpoCanceledError(error)) { + return true; + } + const parsed = ErrorCauseSchema.safeParse(error); + return parsed.success && parsed.data.cause !== undefined + ? isExpoCanceledError(parsed.data.cause) + : false; + } catch { + return false; + } +} + +function isExpoCanceledError(error: unknown): boolean { + try { + if (hasErrorName(error, EXPO_CANCEL_NAME)) { + return true; + } + const byCode = ErrorCodeSchema.safeParse(error); + if (byCode.success && byCode.data.code === EXPO_CANCEL_NAME) { + return true; + } + const byMessage = ErrorMessageSchema.safeParse(error); + return byMessage.success && byMessage.data.message.includes(EXPO_CANCEL_NAME); + } catch { + return false; + } } function hasErrorName(error: unknown, name: string): boolean {