match lex errors with instanceof, drop getErrorName

`getErrorName` erased the typed error classes by flattening everything to a
string code. Anchor on the classes instead: `LexAuthFactorError` for the login
2FA branch, `XrpcResponseError` for the signup error codes.

`@atproto/lex-client` stays a direct dependency because `XrpcResponseError` is
used at runtime for `instanceof`. It is already in the runtime graph via
`@atproto/lex-password-session`, so the direct declaration adds no bundle
weight; it makes the import legal under pnpm's strict layout and pins the
version so both packages share one `LexError` identity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-07-31 20:38:33 +03:00
parent 1d279dc894
commit 21d67da9a9
4 changed files with 18 additions and 87 deletions
-54
View File
@@ -1,54 +0,0 @@
import {XrpcResponseError} from '@atproto/lex-client'
import {LexAuthFactorError} from '@atproto/lex-password-session'
import {describe, expect, it} from '@jest/globals'
import {getErrorName} from '../lex-error'
/**
* A stand-in for the method schema an `XrpcResponseError` is built against.
* The generated lexicons are not available yet, and `XrpcResponseError` only
* reads `method` back out for `matchesSchemaErrors()`, which these tests never
* call - so a minimal object is enough.
*/
const method = {
nsid: 'com.atproto.server.createSession',
type: 'procedure',
errors: ['AuthFactorTokenRequired'],
} as unknown as ConstructorParameters<typeof XrpcResponseError>[0]
/** An error as the lex client builds one from a JSON error response body. */
function xrpcResponseError(error: string, message: string) {
return new XrpcResponseError(method, new Response(null, {status: 400}), {
encoding: 'application/json',
body: {error, message},
})
}
describe('getErrorName', () => {
it('returns the lexicon error code of an XRPC response error', () => {
const e = xrpcResponseError('InvalidToken', 'Bad token scope')
expect(getErrorName(e)).toBe('InvalidToken')
})
it('returns AuthFactorTokenRequired for a LexAuthFactorError', () => {
/*
* The 2FA case: `PasswordSession.login` throws this, and it extends
* `LexError` WITHOUT being an `XrpcError` - the reason `getErrorName` is
* gated on `LexError`.
*/
const cause = xrpcResponseError(
'AuthFactorTokenRequired',
'A sign in code has been sent to your email address',
)
expect(getErrorName(new LexAuthFactorError(cause))).toBe(
'AuthFactorTokenRequired',
)
})
it('returns undefined for non-lex errors', () => {
expect(getErrorName(new Error('InvalidToken'))).toBeUndefined()
expect(getErrorName('InvalidToken')).toBeUndefined()
expect(getErrorName(null)).toBeUndefined()
expect(getErrorName(undefined)).toBeUndefined()
})
})
-13
View File
@@ -1,13 +0,0 @@
import {LexError} from '@atproto/lex-client'
/**
* The lexicon error code (`err.error`). Gated on `LexError` (the base of the
* lex error hierarchy) rather than `XrpcError` so sibling `LexError` subclasses
* that are NOT `XrpcError` also surface their `.error` - notably
* `LexAuthFactorError` (`'AuthFactorTokenRequired'`), which `PasswordSession`
* throws for email-2FA logins. Every `XrpcError` is a `LexError`, so gating on
* the base covers server error responses too.
*/
export function getErrorName(e: unknown): string | undefined {
return e instanceof LexError ? e.error : undefined
}
+4 -5
View File
@@ -1,10 +1,10 @@
import {useRef, useState} from 'react'
import {Keyboard, type TextInput, View} from 'react-native'
import {type ComAtprotoServerDescribeServer} from '@atproto/api'
import {LexAuthFactorError} from '@atproto/lex-password-session'
import {Trans, useLingui} from '@lingui/react/macro'
import {DEFAULT_SERVICE, HITSLOP_10, HITSLOP_20} from '#/lib/constants'
import {getErrorName} from '#/lib/lex-error'
import {useRequestNotificationsPermission} from '#/lib/notifications/notifications'
import {cleanError, isNetworkError} from '#/lib/strings/errors'
import {createFullHandle} from '#/lib/strings/handles'
@@ -141,11 +141,10 @@ export const LoginForm = ({
const errMsg = String(err)
setIsProcessing(false)
/*
* Matches a `LexAuthFactorError` from `PasswordSession.login`, which is
* NOT an `XrpcError` - so `getErrorName`, gated on `LexError`, is
* required here.
* `LexAuthFactorError` is what `PasswordSession.login` throws when the
* server demands an email 2FA token.
*/
if (getErrorName(err) === 'AuthFactorTokenRequired') {
if (err instanceof LexAuthFactorError) {
setIsAuthFactorTokenNeeded(true)
} else {
onAttemptFailed()
+14 -15
View File
@@ -1,11 +1,11 @@
import {createContext, useCallback, useContext} from 'react'
import {LayoutAnimation} from 'react-native'
import {type ComAtprotoServerDescribeServer} from '@atproto/api'
import {XrpcResponseError} from '@atproto/lex-client'
import {useLingui} from '@lingui/react/macro'
import * as EmailValidator from 'email-validator'
import {DEFAULT_SERVICE} from '#/lib/constants'
import {getErrorName} from '#/lib/lex-error'
import {cleanError, isNetworkError} from '#/lib/strings/errors'
import {createFullHandle} from '#/lib/strings/handles'
import {getAge} from '#/lib/strings/time'
@@ -259,20 +259,19 @@ export const useSignupContext = () => useContext(SignupContext)
*/
function classifyExpectedSignupError(e: unknown): string | undefined {
/*
* `createAccount` now goes through `PasswordSession`, so these arrive as lex
* error codes rather than the generated `@atproto/api` error classes.
* Comparing the code as a plain string means a typo silently never matches; a
* typed variant constrained to the errors the lexicon declares arrives with
* the lexicon codegen in a later PR.
* TODO: `XrpcResponseError.error` is the open `LexErrorCode` union, so these
* codes are compared as plain strings and a typo silently never matches.
* Narrowing to the errors `com.atproto.server.createAccount` declares needs
* the generated lexicons.
*/
const name = getErrorName(e)
if (
name === 'InvalidHandle' ||
name === 'HandleNotAvailable' ||
name === 'InvalidPassword' ||
name === 'UnsupportedDomain'
) {
return name
if (e instanceof XrpcResponseError) {
switch (e.error) {
case 'InvalidHandle':
case 'HandleNotAvailable':
case 'InvalidPassword':
case 'UnsupportedDomain':
return e.error
}
}
/* the server sends no typed error for this case */
if (String(e).includes('Email already taken')) return 'EmailTaken'
@@ -363,7 +362,7 @@ export function useSubmitSignup() {
} catch (err) {
const e = err as Error
let errMsg = e.toString()
if (getErrorName(e) === 'InvalidInviteCode') {
if (e instanceof XrpcResponseError && e.error === 'InvalidInviteCode') {
dispatch({
type: 'setError',
value: l`Invite code not accepted. Check that you input it correctly and try again.`,