add dual-world xrpc error helpers and toLex migration escape hatch
Phase 3 foundations (task 1): src/lib/xrpc-error.ts matches both the old @atproto/api XRPCError and lex-client XrpcError/XrpcResponseError during the migration; errors.ts token-invalid matching goes through it. toLex<T>() added to #/types/bsky as a marked interim cast for mixed-world boundaries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, string>,
|
||||||
|
) {
|
||||||
|
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<string, string>,
|
||||||
|
) {
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import {XRPCError} from '@atproto/api'
|
|
||||||
import {t} from '@lingui/core/macro'
|
import {t} from '@lingui/core/macro'
|
||||||
|
|
||||||
|
import {getErrorName, getErrorStatus, isXrpcError} from '#/lib/xrpc-error'
|
||||||
|
|
||||||
export function cleanError(e: unknown): string {
|
export function cleanError(e: unknown): string {
|
||||||
if (!e) {
|
if (!e) {
|
||||||
return ''
|
return ''
|
||||||
@@ -67,7 +68,7 @@ export function isNetworkError(e: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isErrorMaybeAppPasswordPermissions(e: unknown) {
|
export function isErrorMaybeAppPasswordPermissions(e: unknown) {
|
||||||
if (e instanceof XRPCError && e.error === 'TokenInvalid') {
|
if (isXrpcError(e) && getErrorName(e) === 'TokenInvalid') {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
const str = String(e)
|
const str = String(e)
|
||||||
@@ -93,5 +94,6 @@ export function isRetryableHttpStatus(status: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function shouldRetryError(e: unknown) {
|
export function shouldRetryError(e: unknown) {
|
||||||
return e instanceof XRPCError && isRetryableHttpStatus(e.status)
|
const status = getErrorStatus(e)
|
||||||
|
return status !== undefined && isRetryableHttpStatus(status)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, string>}).headers
|
||||||
|
return h?.[name.toLowerCase()]
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
@@ -16,6 +16,17 @@ export * as post from '#/types/bsky/post'
|
|||||||
export * as profile from '#/types/bsky/profile'
|
export * as profile from '#/types/bsky/profile'
|
||||||
export * as starterPack from '#/types/bsky/starterPack'
|
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<T>(v: unknown): T {
|
||||||
|
return v as T
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fast type checking without full schema validation, for use with data we
|
* 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
|
* trust, or for non-critical path use cases. Why? Our SDK's `is*` identity
|
||||||
|
|||||||
Reference in New Issue
Block a user