Skip to content
Merged
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
36 changes: 36 additions & 0 deletions apps/mobile/src/lib/telemetry/network-errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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' });
Expand Down
46 changes: 44 additions & 2 deletions apps/mobile/src/lib/telemetry/network-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,17 @@ export type TrpcResponseError = z.infer<typeof TrpcResponseErrorSchema>;
// 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;

Expand Down Expand Up @@ -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 {
Expand Down
Loading