diff --git a/src/lib/strings/__tests__/errors.test.ts b/src/lib/strings/__tests__/errors.test.ts new file mode 100644 index 0000000000..aa9538032f --- /dev/null +++ b/src/lib/strings/__tests__/errors.test.ts @@ -0,0 +1,139 @@ +import {XRPCError} from '@atproto/api' +import { + type Procedure, + type Query, + XrpcResponseError, +} from '@atproto/lex-client' +import {describe, expect, it} from '@jest/globals' + +import { + getErrorHeader, + getErrorName, + getErrorStatus, + isXrpcError, +} from '#/lib/xrpc-error' +import {isErrorMaybeAppPasswordPermissions, isNetworkError} from '../errors' + +/** + * Old-world fixture: `@atproto/api` XRPCError. `.headers` is a plain record, + * `.error` is the lexicon code string, `.status` a numeric ResponseType enum. + */ +function oldError( + status: number, + error: string, + headers?: Record, +) { + return new XRPCError(status, error, undefined, headers) +} + +/** + * New-world fixture: lex `XrpcResponseError`. Built from a WHATWG `Response` + * (so `.headers` is a `Headers` object and `.status` is the numeric HTTP + * status) plus a JSON error payload carrying `.error`. + */ +function lexError( + status: number, + error: string, + headers?: Record, +) { + const response = new Response(null, {status, headers}) + const method = {} as Procedure | Query + return new XrpcResponseError(method, response, { + encoding: 'application/json', + body: {error, message: `${error} message`}, + }) +} + +describe('isXrpcError', () => { + it('matches both old XRPCError and lex XrpcResponseError', () => { + expect(isXrpcError(oldError(400, 'TokenInvalid'))).toBe(true) + expect(isXrpcError(lexError(400, 'TokenInvalid'))).toBe(true) + }) + + it('rejects non-XRPC values', () => { + expect(isXrpcError(new Error('boom'))).toBe(false) + expect(isXrpcError('TokenInvalid')).toBe(false) + expect(isXrpcError(undefined)).toBe(false) + }) +}) + +describe('getErrorStatus', () => { + it('reads status from both worlds', () => { + expect(typeof getErrorStatus(oldError(400, 'TokenInvalid'))).toBe('number') + expect(getErrorStatus(lexError(429, 'RateLimitExceeded'))).toBe(429) + }) + + it('returns undefined for non-XRPC values', () => { + expect(getErrorStatus(new Error('boom'))).toBeUndefined() + }) +}) + +describe('getErrorName', () => { + it('reads the lexicon error code from both worlds', () => { + expect(getErrorName(oldError(400, 'TokenInvalid'))).toBe('TokenInvalid') + expect(getErrorName(lexError(400, 'TokenInvalid'))).toBe('TokenInvalid') + }) + + it('returns undefined for non-XRPC values', () => { + expect(getErrorName(new Error('boom'))).toBeUndefined() + }) +}) + +describe('getErrorHeader', () => { + it('reads a header from the old record shape (case-insensitive)', () => { + const e = oldError(429, 'RateLimitExceeded', {'ratelimit-reset': '123'}) + expect(getErrorHeader(e, 'ratelimit-reset')).toBe('123') + expect(getErrorHeader(e, 'RateLimit-Reset')).toBe('123') + }) + + it('reads a header from the lex Headers object', () => { + const e = lexError(429, 'RateLimitExceeded', {'ratelimit-reset': '123'}) + expect(getErrorHeader(e, 'ratelimit-reset')).toBe('123') + expect(getErrorHeader(e, 'RateLimit-Reset')).toBe('123') + }) + + it('returns undefined for a missing header or non-XRPC value', () => { + expect(getErrorHeader(lexError(400, 'X'), 'nope')).toBeUndefined() + expect(getErrorHeader(new Error('boom'), 'ratelimit-reset')).toBeUndefined() + }) +}) + +describe('isErrorMaybeAppPasswordPermissions', () => { + it('matches a TokenInvalid error from both worlds', () => { + expect( + isErrorMaybeAppPasswordPermissions(oldError(400, 'TokenInvalid')), + ).toBe(true) + expect( + isErrorMaybeAppPasswordPermissions(lexError(400, 'TokenInvalid')), + ).toBe(true) + }) + + it('still matches the string-based bad-token signals', () => { + expect( + isErrorMaybeAppPasswordPermissions(new Error('Bad token scope')), + ).toBe(true) + expect( + isErrorMaybeAppPasswordPermissions(new Error('Bad token method')), + ).toBe(true) + }) + + it('does not match unrelated XRPC errors', () => { + expect( + isErrorMaybeAppPasswordPermissions(oldError(400, 'InvalidRequest')), + ).toBe(false) + expect( + isErrorMaybeAppPasswordPermissions(lexError(400, 'InvalidRequest')), + ).toBe(false) + }) +}) + +describe('isNetworkError', () => { + it('matches known network failure strings', () => { + expect(isNetworkError(new Error('Network request failed'))).toBe(true) + expect(isNetworkError('Failed to fetch')).toBe(true) + }) + + it('does not match unrelated errors', () => { + expect(isNetworkError(new Error('TokenInvalid'))).toBe(false) + }) +}) diff --git a/src/lib/strings/errors.ts b/src/lib/strings/errors.ts index 11e7c11362..c737be01b3 100644 --- a/src/lib/strings/errors.ts +++ b/src/lib/strings/errors.ts @@ -1,6 +1,7 @@ -import {XRPCError} from '@atproto/api' import {t} from '@lingui/core/macro' +import {getErrorName, getErrorStatus, isXrpcError} from '#/lib/xrpc-error' + export function cleanError(e: unknown): string { if (!e) { return '' @@ -67,7 +68,7 @@ export function isNetworkError(e: unknown) { } export function isErrorMaybeAppPasswordPermissions(e: unknown) { - if (e instanceof XRPCError && e.error === 'TokenInvalid') { + if (isXrpcError(e) && getErrorName(e) === 'TokenInvalid') { return true } const str = String(e) @@ -93,5 +94,6 @@ export function isRetryableHttpStatus(status: number) { } export function shouldRetryError(e: unknown) { - return e instanceof XRPCError && isRetryableHttpStatus(e.status) + const status = getErrorStatus(e) + return status !== undefined && isRetryableHttpStatus(status) } diff --git a/src/lib/xrpc-error.ts b/src/lib/xrpc-error.ts new file mode 100644 index 0000000000..bc23fda70a --- /dev/null +++ b/src/lib/xrpc-error.ts @@ -0,0 +1,44 @@ +import {XRPCError} from '@atproto/api' +import {XrpcError, XrpcResponseError} from '@atproto/lex-client' + +/** + * True for an XRPC error from either the old bridge agent (`@atproto/api` + * `XRPCError`) or a lex `Client` (`@atproto/lex-client` `XrpcError`, the + * abstract base of `XrpcResponseError`/`XrpcInvalidResponseError`/ + * `XrpcInternalError`). During the migration both worlds can throw, so matchers + * must accept both. + */ +export function isXrpcError(e: unknown): e is XRPCError | XrpcError { + return e instanceof XRPCError || e instanceof XrpcError +} + +/** + * HTTP status, or undefined if not an XRPC error / no response. Only lex + * `XrpcResponseError` (a server response) carries a status; the internal/fetch + * lex errors do not. + */ +export function getErrorStatus(e: unknown): number | undefined { + if (e instanceof XRPCError) return e.status + if (e instanceof XrpcResponseError) return e.status + return undefined +} + +/** The lexicon error code (`err.error`), from either world. */ +export function getErrorName(e: unknown): string | undefined { + if (isXrpcError(e)) return (e as {error?: string}).error + return undefined +} + +/** + * Read a response header off an XRPC error, normalizing the shape change: + * old XRPCError.headers is a plain record; lex XrpcResponseError.headers is a + * WHATWG Headers object. + */ +export function getErrorHeader(e: unknown, name: string): string | undefined { + if (e instanceof XrpcResponseError) return e.headers.get(name) ?? undefined + if (e instanceof XRPCError) { + const h = (e as {headers?: Record}).headers + return h?.[name.toLowerCase()] + } + return undefined +} diff --git a/src/types/bsky/index.ts b/src/types/bsky/index.ts index 5e168858cb..e5b292e3a2 100644 --- a/src/types/bsky/index.ts +++ b/src/types/bsky/index.ts @@ -16,6 +16,17 @@ export * as post from '#/types/bsky/post' export * as profile from '#/types/bsky/profile' export * as starterPack from '#/types/bsky/starterPack' +/** + * Unsafe cast from an old `@atproto/api` view/record type to its `#/lexicons` + * equivalent. Only for mixed-world boundaries during the migration where a + * producer has not yet flipped. Structurally the shapes are identical modulo + * branded string types; this asserts the brand the compiler cannot prove. + * Every use is a migration debt marker - grep `toLex` at Phase 4 cleanup. + */ +export function toLex(v: unknown): T { + return v as T +} + /** * Fast type checking without full schema validation, for use with data we * trust, or for non-critical path use cases. Why? Our SDK's `is*` identity