match xrpc errors against the method schema
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
getMain,
|
||||
type InferMethodError,
|
||||
type Main,
|
||||
type Procedure,
|
||||
type Query,
|
||||
XrpcResponseError,
|
||||
} from '@atproto/lex'
|
||||
|
||||
/**
|
||||
* 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: Procedure | Query = getMain(method)
|
||||
const thrownFor: Procedure | Query = e.method
|
||||
if (thrownFor.nsid !== schema.nsid) {
|
||||
return undefined
|
||||
}
|
||||
return schema.errors?.includes(e.error)
|
||||
? (e.error as InferMethodError<M>)
|
||||
: undefined
|
||||
}
|
||||
+13
-30
@@ -1,7 +1,6 @@
|
||||
import {createContext, useCallback, useContext} from 'react'
|
||||
import {LayoutAnimation} from 'react-native'
|
||||
import {type ComAtprotoServerDescribeServer} from '@atproto/api'
|
||||
import {XrpcResponseError} from '@atproto/lex'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
import * as EmailValidator from 'email-validator'
|
||||
|
||||
@@ -9,9 +8,11 @@ import {DEFAULT_SERVICE} from '#/lib/constants'
|
||||
import {cleanError, isNetworkError} from '#/lib/strings/errors'
|
||||
import {createFullHandle} from '#/lib/strings/handles'
|
||||
import {getAge} from '#/lib/strings/time'
|
||||
import {matchXrpcError} from '#/lib/xrpc-error'
|
||||
import {useSessionApi} from '#/state/session'
|
||||
import {useOnboardingDispatch} from '#/state/shell'
|
||||
import {type AnalyticsContextType, useAnalytics} from '#/analytics'
|
||||
import {com} from '#/lexicons'
|
||||
|
||||
export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
|
||||
|
||||
@@ -258,34 +259,13 @@ export const useSignupContext = () => useContext(SignupContext)
|
||||
* failure is unexpected and should be reported to Sentry.
|
||||
*/
|
||||
function classifyExpectedSignupError(e: unknown): string | undefined {
|
||||
/*
|
||||
* TODO: `XrpcResponseError.error` is the open `LexErrorCode` union, so these
|
||||
* codes are compared as plain strings and a typo silently never matches.
|
||||
* Once the generated lexicons land, replace this (and every multi-code error
|
||||
* site) with a shared helper that narrows against the method schema:
|
||||
*
|
||||
* function matchXrpcError<M extends Procedure | Query>(
|
||||
* e: unknown,
|
||||
* method: Main<M>,
|
||||
* ): InferMethodError<M> | undefined
|
||||
*
|
||||
* switch (matchXrpcError(e, com.atproto.server.createAccount)) {
|
||||
* case 'InvalidHandle': ...
|
||||
* }
|
||||
*
|
||||
* The return type is the method's declared-errors union, so a typo'd case is
|
||||
* a compile error, and undeclared codes fall through to `undefined`. The
|
||||
* helper should also match `e.method.nsid` so a declared code from a
|
||||
* different call cannot match, mirroring the old per-method error classes.
|
||||
*/
|
||||
if (e instanceof XrpcResponseError) {
|
||||
switch (e.error) {
|
||||
case 'InvalidHandle':
|
||||
case 'HandleNotAvailable':
|
||||
case 'InvalidPassword':
|
||||
case 'UnsupportedDomain':
|
||||
return e.error
|
||||
}
|
||||
const code = matchXrpcError(e, com.atproto.server.createAccount)
|
||||
switch (code) {
|
||||
case 'InvalidHandle':
|
||||
case 'HandleNotAvailable':
|
||||
case 'InvalidPassword':
|
||||
case 'UnsupportedDomain':
|
||||
return code
|
||||
}
|
||||
/* the server sends no typed error for this case */
|
||||
if (String(e).includes('Email already taken')) return 'EmailTaken'
|
||||
@@ -376,7 +356,10 @@ export function useSubmitSignup() {
|
||||
} catch (err) {
|
||||
const e = err as Error
|
||||
let errMsg = e.toString()
|
||||
if (e instanceof XrpcResponseError && e.error === 'InvalidInviteCode') {
|
||||
if (
|
||||
matchXrpcError(e, com.atproto.server.createAccount) ===
|
||||
'InvalidInviteCode'
|
||||
) {
|
||||
dispatch({
|
||||
type: 'setError',
|
||||
value: l`Invite code not accepted. Check that you input it correctly and try again.`,
|
||||
|
||||
Reference in New Issue
Block a user