narrow abort pattern and harden isNetworkError

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-09-02 18:19:16 +03:00
parent 1d136b07ed
commit 2e652ba619
3 changed files with 127 additions and 8 deletions
+67
View File
@@ -1,20 +1,46 @@
import {getMain, XrpcInternalError} from '@atproto/lex'
import {describe, expect, test} from '@jest/globals' import {describe, expect, test} from '@jest/globals'
import {AbortError} from '#/lib/async/cancelable'
import {isNetworkError} from '#/lib/network-error' import {isNetworkError} from '#/lib/network-error'
import {com} from '#/lexicons'
const method = getMain(com.atproto.server.describeServer)
describe('isNetworkError', () => { describe('isNetworkError', () => {
test.each([ test.each([
'Network request failed', 'Network request failed',
'TypeError: Failed to fetch', 'TypeError: Failed to fetch',
'Error: fetch failed: java.net.UnknownHostException', 'Error: fetch failed: java.net.UnknownHostException',
'Load failed',
'Error: Upstream service unreachable',
'NetworkError when attempting to fetch resource',
'The Internet connection appears to be offline', 'The Internet connection appears to be offline',
'The network connection was lost', 'The network connection was lost',
'Unable to resolve host "bsky.social": No address associated with hostname',
'A server with the specified hostname could not be found', 'A server with the specified hostname could not be found',
'TypeError: Network request timed out', 'TypeError: Network request timed out',
'ConnectException: Failed to connect to bsky.social/1.2.3.4:443',
'SSLHandshakeException: Connection closed by peer',
])('detects %s', message => { ])('detects %s', message => {
expect(isNetworkError(new Error(message))).toBe(true) expect(isNetworkError(new Error(message))).toBe(true)
}) })
test('detects a cancelled request', () => {
expect(isNetworkError(new AbortError())).toBe(true)
})
test('detects a stringified web abort', () => {
expect(isNetworkError('AbortError: The user aborted a request.')).toBe(true)
})
test.each([
'MultipartUploadError: Multipart upload aborted',
'TypeError: undefined is not an object (evaluating abortController.abort)',
])('does not treat a lowercase abort as a network error: %s', message => {
expect(isNetworkError(message)).toBe(false)
})
test('checks wrapped error causes', () => { test('checks wrapped error causes', () => {
const error = new Error('Unable to fulfill XRPC request', { const error = new Error('Unable to fulfill XRPC request', {
cause: new Error('fetch failed: connection closed'), cause: new Error('fetch failed: connection closed'),
@@ -23,6 +49,23 @@ describe('isNetworkError', () => {
expect(isNetworkError(error)).toBe(true) expect(isNetworkError(error)).toBe(true)
}) })
test('checks the cause of a wrapper that does not embed it', () => {
const error = new XrpcInternalError(method, undefined, {
cause: new Error('fetch failed'),
})
expect(error.message).not.toContain('fetch failed')
expect(isNetworkError(error)).toBe(true)
})
test('keeps a wrapper whose cause is not a network error', () => {
const error = new XrpcInternalError(method, undefined, {
cause: new TypeError('URL.canParse is not a function'),
})
expect(isNetworkError(error)).toBe(false)
})
test('does not treat arbitrary fetch-handler failures as network errors', () => { test('does not treat arbitrary fetch-handler failures as network errors', () => {
expect( expect(
isNetworkError( isNetworkError(
@@ -36,4 +79,28 @@ describe('isNetworkError', () => {
test('does not match words ending in load', () => { test('does not match words ending in load', () => {
expect(isNetworkError(new Error('Multipart upload failed'))).toBe(false) expect(isNetworkError(new Error('Multipart upload failed'))).toBe(false)
}) })
test('terminates on a cycle of causes', () => {
const a = new Error('first') as Error & {cause?: unknown}
const b = new Error('second') as Error & {cause?: unknown}
a.cause = b
b.cause = a
expect(isNetworkError(a)).toBe(false)
})
test.each([
['a null-prototype object', () => Object.create(null)],
[
'a value whose toString throws',
() => ({
toString() {
throw new Error('nope')
},
}),
],
])('returns false without throwing for %s', (_label, build) => {
expect(() => isNetworkError(build())).not.toThrow()
expect(isNetworkError(build())).toBe(false)
})
}) })
+54 -7
View File
@@ -1,5 +1,12 @@
const NETWORK_ERROR_PATTERNS = [ const NETWORK_ERROR_PATTERNS = [
/\babort(?:ed|error)?\b/i, /*
* Case-sensitive on purpose. `AbortError` (web `DOMException`) and the
* `Aborted` message thrown by `#/lib/async/cancelable` are transport
* cancellations, but the lowercase word shows up in unrelated failures - a
* multipart upload the service marked `aborted`, or a `TypeError` naming
* `abortController.abort` - which must still be reported.
*/
/\bAbort(?:ed|Error)?\b/,
/network request failed/i, /network request failed/i,
/failed to fetch/i, /failed to fetch/i,
/fetch failed/i, /fetch failed/i,
@@ -16,24 +23,64 @@ const NETWORK_ERROR_PATTERNS = [
/sslhandshakeexception: connection closed/i, /sslhandshakeexception: connection closed/i,
] ]
/**
* `String(value)` runs code we do not control - a custom `toString`, a
* `Symbol.toPrimitive`, a `Proxy` trap - and throws outright for a
* null-prototype object, so stringifying an arbitrary thrown value can itself
* throw. Returning `undefined` lets callers treat an unreadable value as "not a
* network error" rather than letting the throw escape `logger.error()` or
* Sentry's `beforeSend`, where it would lose the original report.
*/
export function safeStringify(value: unknown): string | undefined {
try {
// oxlint-disable-next-line typescript/no-base-to-string
return String(value)
} catch {
return undefined
}
}
/** /**
* Detects transport failures across the error strings produced by web, native * Detects transport failures across the error strings produced by web, native
* fetch, and the XRPC clients. Error causes are checked because the XRPC * fetch, and the XRPC clients. Error causes are checked because the XRPC
* clients wrap the platform-specific fetch error. * clients wrap the platform-specific fetch error.
*
* Never throws: reading `cause` off a hostile value can throw just like
* stringifying it can, and a throw here would escape whatever error path is
* asking the question.
*/ */
export function isNetworkError(value: unknown): boolean { export function isNetworkError(value: unknown): boolean {
return isNetworkErrorInner(value, new Set()) try {
return isNetworkErrorInner(value, undefined)
} catch {
return false
}
} }
function isNetworkErrorInner(value: unknown, seen: Set<object>): boolean { /**
if (NETWORK_ERROR_PATTERNS.some(pattern => pattern.test(String(value)))) { * `seen` stays undefined until the first recursion into an object cause, so the
* common case (a string or an error with no cause) allocates nothing.
*/
function isNetworkErrorInner(
value: unknown,
seen: Set<object> | undefined,
): boolean {
const message = safeStringify(value)
if (
message !== undefined &&
NETWORK_ERROR_PATTERNS.some(pattern => pattern.test(message))
) {
return true return true
} }
if (typeof value !== 'object' || value === null || seen.has(value)) { if (typeof value !== 'object' || value === null || seen?.has(value)) {
return false
}
if (!('cause' in value)) {
return false return false
} }
seen.add(value) const nextSeen = seen ?? new Set<object>()
return 'cause' in value && isNetworkErrorInner(value.cause, seen) nextSeen.add(value)
return isNetworkErrorInner(value.cause, nextSeen)
} }
+6 -1
View File
@@ -39,7 +39,12 @@ export function cleanError(e: unknown): string {
*/ */
// oxlint-disable-next-line typescript/no-base-to-string // oxlint-disable-next-line typescript/no-base-to-string
const str = typeof e === 'string' ? e : e.toString() const str = typeof e === 'string' ? e : e.toString()
if (isNetworkError(str)) { /*
* Passed the original value, not `str`, so the wrapped-cause walk is
* reachable: the XRPC clients report the platform fetch failure as the
* `cause` of a generic message.
*/
if (isNetworkError(e)) {
return t`Unable to connect. Please check your internet connection and try again.` return t`Unable to connect. Please check your internet connection and try again.`
} }
/* /*