34c00e945d
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
169 lines
5.1 KiB
TypeScript
169 lines
5.1 KiB
TypeScript
import {getMain, XrpcInternalError, XrpcResponseError} from '@atproto/lex'
|
|
import {describe, expect, test} from '@jest/globals'
|
|
import {type ErrorEvent} from '@sentry/react-native'
|
|
|
|
import {
|
|
dropExpectedNetworkErrors,
|
|
isExpectedSentryNetworkError,
|
|
} from '#/logger/sentry/network-errors'
|
|
import {com} from '#/lexicons'
|
|
|
|
const method = getMain(com.atproto.server.describeServer)
|
|
|
|
/** An `XrpcResponseError` for a response that carried no JSON error payload. */
|
|
function statusOnlyError(status: number) {
|
|
return new XrpcResponseError(method, new Response(null, {status}), undefined)
|
|
}
|
|
|
|
/** A pipethrough `XrpcResponseError`: status rewritten, code forwarded. */
|
|
function payloadError(status: number, error: string, message: string) {
|
|
const body = {error, message}
|
|
return new XrpcResponseError(
|
|
method,
|
|
new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: {'content-type': 'application/json'},
|
|
}),
|
|
{encoding: 'application/json', body},
|
|
)
|
|
}
|
|
|
|
function exceptionEvent(type: string, value: string): ErrorEvent {
|
|
return {type: undefined, exception: {values: [{type, value}]}}
|
|
}
|
|
|
|
describe('isExpectedSentryNetworkError', () => {
|
|
test.each([502, 503, 504])('detects transient upstream status %s', status => {
|
|
expect(isExpectedSentryNetworkError(statusOnlyError(status))).toBe(true)
|
|
})
|
|
|
|
test.each([429, 500])('keeps actionable HTTP status %s', status => {
|
|
expect(isExpectedSentryNetworkError(statusOnlyError(status))).toBe(false)
|
|
})
|
|
|
|
test('detects a pipethrough 502 forwarding an upstream error code', () => {
|
|
const error = payloadError(
|
|
502,
|
|
'InternalServerError',
|
|
'Internal Server Error',
|
|
)
|
|
|
|
expect(error.error).toBe('InternalServerError')
|
|
expect(isExpectedSentryNetworkError(error)).toBe(true)
|
|
})
|
|
|
|
test('checks the cause of a wrapper that does not embed it', () => {
|
|
expect(
|
|
isExpectedSentryNetworkError(
|
|
new XrpcInternalError(method, undefined, {
|
|
cause: new Error('fetch failed: connection reset'),
|
|
}),
|
|
),
|
|
).toBe(true)
|
|
})
|
|
|
|
test('keeps implementation errors wrapped by XRPC errors', () => {
|
|
expect(
|
|
isExpectedSentryNetworkError(
|
|
new XrpcInternalError(method, undefined, {
|
|
cause: new TypeError('URL.canParse is not a function'),
|
|
}),
|
|
),
|
|
).toBe(false)
|
|
})
|
|
|
|
test.each(['UpstreamFailure', 'Upstream Failure', '[UpstreamFailure]'])(
|
|
'detects the transient upstream message %s',
|
|
message => {
|
|
expect(isExpectedSentryNetworkError(message)).toBe(true)
|
|
},
|
|
)
|
|
|
|
test.each(['upstreamTimeoutV2', 'handleUpstreamFailureRetry'])(
|
|
'keeps the camelCase identifier %s',
|
|
message => {
|
|
expect(isExpectedSentryNetworkError(message)).toBe(false)
|
|
},
|
|
)
|
|
})
|
|
|
|
describe('dropExpectedNetworkErrors', () => {
|
|
test('drops automatic captures using the original exception', () => {
|
|
// the serialized values carry no cause chain, only the hint has the failure
|
|
const event = exceptionEvent(
|
|
'XrpcInternalError',
|
|
'Unable to fulfill XRPC request',
|
|
)
|
|
|
|
expect(
|
|
dropExpectedNetworkErrors(event, {
|
|
originalException: new XrpcInternalError(method, undefined, {
|
|
cause: new Error('fetch failed: connection closed'),
|
|
}),
|
|
}),
|
|
).toBeNull()
|
|
})
|
|
|
|
test('checks every exception in a linked error chain', () => {
|
|
const event = {
|
|
type: undefined,
|
|
exception: {
|
|
values: [
|
|
{type: 'Error', value: 'Network request failed'},
|
|
{
|
|
type: 'XrpcInternalError',
|
|
value: 'Unable to fulfill XRPC request',
|
|
},
|
|
],
|
|
},
|
|
} satisfies ErrorEvent
|
|
|
|
expect(dropExpectedNetworkErrors(event, {})).toBeNull()
|
|
})
|
|
|
|
test('drops a serialized transient upstream error carrying a payload', () => {
|
|
const event = exceptionEvent('XrpcResponseError', 'Upstream Timeout')
|
|
|
|
expect(dropExpectedNetworkErrors(event, {})).toBeNull()
|
|
})
|
|
|
|
test('drops a serialized transient upstream error with no payload', () => {
|
|
const {message} = statusOnlyError(504)
|
|
|
|
// Locks the text lex builds from the status alone, which the filter matches
|
|
expect(message).toBe('Upstream server responded with a 504 error')
|
|
expect(
|
|
dropExpectedNetworkErrors(
|
|
exceptionEvent('XrpcResponseError', message),
|
|
{},
|
|
),
|
|
).toBeNull()
|
|
})
|
|
|
|
test.each([
|
|
'[USER REPORT] alice.bsky.social video-upload-aborted',
|
|
'[USER REPORT] alice.bsky.social Network request failed',
|
|
])('keeps the message event %s', message => {
|
|
const event = {type: undefined, message} satisfies ErrorEvent
|
|
|
|
expect(dropExpectedNetworkErrors(event, {originalException: message})).toBe(
|
|
event,
|
|
)
|
|
})
|
|
|
|
test('keeps non-network events', () => {
|
|
const event = exceptionEvent(
|
|
'XrpcFetchError',
|
|
'Unexpected fetchHandler() error: URL.canParse is not a function',
|
|
)
|
|
|
|
expect(dropExpectedNetworkErrors(event, {})).toBe(event)
|
|
})
|
|
|
|
test('keeps a camelCase identifier that merely contains an upstream word', () => {
|
|
const event = exceptionEvent('Error', 'checkout failed: upstreamTimeoutV2')
|
|
|
|
expect(dropExpectedNetworkErrors(event, {})).toBe(event)
|
|
})
|
|
})
|