[SDK] Add lex client seam and typed xrpc error matching (#11348)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-08-13 22:26:13 +03:00
committed by GitHub
parent 532681d5c0
commit 837771a311
8 changed files with 467 additions and 35 deletions
+91
View File
@@ -0,0 +1,91 @@
import {
getMain,
type Procedure,
type Query,
XrpcInternalError,
XrpcResponseError,
} from '@atproto/lex'
import {describe, expect, it} from '@jest/globals'
import {app, com} from '#/lexicons'
import {matchXrpcError} from '../xrpc-error'
const createAccount = com.atproto.server.createAccount
const getTrends = app.bsky.unspecced.getTrends
/**
* An `XrpcResponseError` as a lex `Client` would construct it: the method
* schema it was thrown for, plus the server's error response and its parsed
* payload.
*/
function responseError(method: Procedure | Query, error: string, status = 400) {
return new XrpcResponseError(
method,
new Response(JSON.stringify({error}), {
status,
headers: {'content-type': 'application/json'},
}),
{encoding: 'application/json', body: {error}},
)
}
describe('matchXrpcError', () => {
it('returns a code declared by the method', () => {
const e = responseError(getMain(createAccount), 'InvalidHandle')
expect(matchXrpcError(e, createAccount)).toBe('InvalidHandle')
})
it('accepts the .main schema as well as the namespace', () => {
const e = responseError(getMain(createAccount), 'InvalidInviteCode')
expect(matchXrpcError(e, createAccount.main)).toBe('InvalidInviteCode')
})
it('returns undefined for a code the method does not declare', () => {
const e = responseError(getMain(createAccount), 'RateLimitExceeded')
expect(matchXrpcError(e, createAccount)).toBeUndefined()
})
it('returns undefined for a method that declares no errors at all', () => {
const e = responseError(getMain(getTrends), 'InvalidHandle')
expect(matchXrpcError(e, getTrends)).toBeUndefined()
})
it('does not match a declared code thrown for a different method', () => {
/*
* `InvalidHandle` is declared by createAccount but this error came from a
* getTrends call, so scoping must reject it.
*/
const e = responseError(getMain(getTrends), 'InvalidHandle')
expect(matchXrpcError(e, createAccount)).toBeUndefined()
})
it('returns undefined for a lex error carrying no server code', () => {
const e = new XrpcInternalError(getMain(createAccount), 'boom')
expect(matchXrpcError(e, createAccount)).toBeUndefined()
})
it('returns undefined for non-lex errors and non-errors', () => {
expect(matchXrpcError(new Error('InvalidHandle'), createAccount)).toBe(
undefined,
)
expect(matchXrpcError('InvalidHandle', createAccount)).toBeUndefined()
expect(matchXrpcError(undefined, createAccount)).toBeUndefined()
})
it('narrows the result to the declared-errors union', () => {
const e = responseError(getMain(createAccount), 'InvalidHandle')
const code = matchXrpcError(e, createAccount)
/*
* A misspelled or undeclared code is not comparable to the narrowed union,
* which is what makes a typo'd `switch` case a compile error (TS2678 /
* TS2367) rather than a branch that never runs.
*/
// @ts-expect-error 'InvalidHandel' is not a declared createAccount error
expect(code === 'InvalidHandel').toBe(false)
// @ts-expect-error 'RateLimitExceeded' is not declared by createAccount
expect(code === 'RateLimitExceeded').toBe(false)
expect(code === 'UnsupportedDomain').toBe(false)
})
})
+26
View File
@@ -0,0 +1,26 @@
import {
type Agent,
type AgentOptions,
Client,
type ClientOptions,
} from '@atproto/lex'
/**
* App-standard factory for lex {@link Client}s. Use this instead of `new
* Client(...)` so every client shares the same lenient response processing.
*
* lex-client defaults to strict Lex processing, which rejects responses
* containing the LEGACY blob reference format (objects with `cid` and
* `mimeType` properties instead of `$type: 'blob'`). Older records on the
* network still carry these, and rejecting them would drop records the app
* currently renders. Lenient mode also relaxes datetime format checks (e.g.
* missing timezones) and blob MIME/size constraints. `Client.configure` only
* accepts `appLabelers` globally, so the option is defaulted here, per
* constructed client.
*/
export function createLexClient(
agent: Agent | AgentOptions,
options?: ClientOptions,
): Client {
return new Client(agent, {strictResponseProcessing: false, ...options})
}
+59
View File
@@ -0,0 +1,59 @@
import {
getMain,
type InferMethodError,
type Main,
type Procedure,
type Query,
XrpcResponseError,
} from '@atproto/lex'
/**
* Same nsid means `e` was thrown for this method schema, so `e` can be
* treated as an `XrpcResponseError<M>` - which is what lets the SDK's
* `matchesSchemaErrors()` narrow `e.error` to M's declared errors.
*/
function isThrownFor<M extends Procedure | Query>(
e: XrpcResponseError,
schema: M,
): e is XrpcResponseError<M> {
return e.method.nsid === schema.nsid
}
/**
* The lexicon error code carried by `e`, narrowed to the errors DECLARED by
* `method`, or `undefined` when `e` is not such an error.
*
* `XrpcResponseError.error` is the open `LexErrorCode` union, so comparing it
* as a plain string lets a typo silently never match. Narrowing the return type
* to `InferMethodError<M>` makes a `switch` over the result reject an
* undeclared or misspelled `case` at compile time:
*
* ```ts
* switch (matchXrpcError(e, com.atproto.server.createAccount)) {
* case 'InvalidHandle':
* ...
* }
* ```
*
* Matching is scoped to `method`: `XrpcError` records the method schema it was
* thrown for, so a declared code arriving from a DIFFERENT call does not match.
* Undeclared codes, non-XRPC errors, and the internal/fetch lex errors (which
* carry no server error code) all return `undefined`.
*
* `method` accepts the same value passed to `client.call` - either the
* generated method namespace (`com.atproto.server.createAccount`) or its
* `.main` schema - via lex's `Main<M>`.
*/
export function matchXrpcError<M extends Procedure | Query>(
e: unknown,
method: Main<M>,
): InferMethodError<M> | undefined {
if (!(e instanceof XrpcResponseError)) {
return undefined
}
const schema = getMain(method)
if (isThrownFor(e, schema) && e.matchesSchemaErrors()) {
return e.error
}
return undefined
}