narrow abort pattern and harden isNetworkError
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
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,
|
||||
/failed to fetch/i,
|
||||
/fetch failed/i,
|
||||
@@ -16,24 +23,64 @@ const NETWORK_ERROR_PATTERNS = [
|
||||
/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
|
||||
* fetch, and the XRPC clients. Error causes are checked because the XRPC
|
||||
* 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 {
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
seen.add(value)
|
||||
return 'cause' in value && isNetworkErrorInner(value.cause, seen)
|
||||
const nextSeen = seen ?? new Set<object>()
|
||||
nextSeen.add(value)
|
||||
return isNetworkErrorInner(value.cause, nextSeen)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user