APP-2997: Handle age assurance data load failures (#11612)

Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
Samuel Newman
2026-09-02 21:35:14 +03:00
committed by GitHub
parent d6e5961adf
commit 457b2be680
11 changed files with 256 additions and 27 deletions
@@ -0,0 +1,30 @@
import {useLingui} from '@lingui/react/macro'
import {useSessionApi} from '#/state/session'
import {Error} from '#/components/Error'
import {EmojiSad_Stroke2_Corner0_Rounded as EmojiSadIcon} from '#/components/icons/Emoji'
import {useOtherRequiredDataQuery} from '#/ageAssurance/data'
import {IS_WEB} from '#/env'
export function DataUnavailableScreen() {
const {t: l} = useLingui()
const {logoutCurrentAccount} = useSessionApi()
const {isFetching, refetch} = useOtherRequiredDataQuery()
return (
<Error
icon={EmojiSadIcon}
title={l`Unable to load your account`}
message={l`We couldn't load your account settings. Check your internet connection and try again.`}
onRetry={() => void refetch()}
isRetrying={isFetching}
secondaryAction={{
label: l`Sign out`,
onPress: () => {
if (IS_WEB) history.pushState(null, '', '/')
logoutCurrentAccount('AgeAssuranceDataUnavailableScreen')
},
}}
/>
)
}
+34 -7
View File
@@ -7,7 +7,7 @@ import {focusManager, QueryClient, useQuery} from '@tanstack/react-query'
import {persistQueryClient} from '@tanstack/react-query-persist-client' import {persistQueryClient} from '@tanstack/react-query-persist-client'
import debounce from 'lodash.debounce' import debounce from 'lodash.debounce'
import {networkRetry} from '#/lib/async/retry' import {isRetryableRequestError, networkRetry} from '#/lib/async/retry'
import {createPersistedQueryStorage} from '#/lib/persisted-query-storage' import {createPersistedQueryStorage} from '#/lib/persisted-query-storage'
import {getAge} from '#/lib/strings/time' import {getAge} from '#/lib/strings/time'
import { import {
@@ -347,9 +347,15 @@ export type OtherRequiredData = {
birthdate: string | undefined birthdate: string | undefined
actorDeclaration?: chat.bsky.actor.declaration.Main actorDeclaration?: chat.bsky.actor.declaration.Main
} }
export type OtherRequiredDataStatus = 'pending' | 'error' | 'success'
const otherRequiredDataRetryOptions = {
retry: (failureCount: number, error: unknown) =>
failureCount < 2 && isRetryableRequestError(error),
}
export function createOtherRequiredDataQueryKey({did}: {did: string}) { export function createOtherRequiredDataQueryKey({did}: {did: string}) {
return ['otherRequiredData', did] return ['otherRequiredData', did]
} }
async function getOtherRequiredData({ async function getOtherRequiredData({
accountClient, accountClient,
}: { }: {
@@ -455,10 +461,11 @@ export async function prefetchOtherRequiredData({
try { try {
logger.debug(`prefetchOtherRequiredData: resolving...`) logger.debug(`prefetchOtherRequiredData: resolving...`)
const res = await networkRetry(3, () => await qc.fetchQuery({
getOtherRequiredData({accountClient}), ...otherRequiredDataRetryOptions,
) queryKey: qk,
qc.setQueryData<OtherRequiredData>(qk, res) queryFn: () => getOtherRequiredData({accountClient}),
})
} catch (err) { } catch (err) {
const e = err as Error const e = err as Error
logger.warn(`prefetchOtherRequiredData: failed`, { logger.warn(`prefetchOtherRequiredData: failed`, {
@@ -490,12 +497,14 @@ export function useOtherRequiredDataQuery() {
const did = accountClient.did const did = accountClient.did
return useQuery( return useQuery(
{ {
...otherRequiredDataRetryOptions,
enabled: !!did, enabled: !!did,
initialData: () => { initialData: () => {
if (!did) return if (!did) return
return getOtherRequiredDataFromCache({did}) return getOtherRequiredDataFromCache({did})
}, },
queryKey: createOtherRequiredDataQueryKey({did: did!}), queryKey: createOtherRequiredDataQueryKey({did: did!}),
retryOnMount: false,
async queryFn() { async queryFn() {
return getOtherRequiredData({accountClient}) return getOtherRequiredData({accountClient})
}, },
@@ -722,6 +731,11 @@ export type AgeAssuranceServerData = {
*/ */
state: app.bsky.ageassurance.defs.State | undefined state: app.bsky.ageassurance.defs.State | undefined
metadata: AgeAssuranceMetadata | undefined metadata: AgeAssuranceMetadata | undefined
/**
* Whether the account data needed to compute age assurance is available.
* A successful response without a birthdate is still `success`.
*/
otherRequiredDataStatus: OtherRequiredDataStatus
/** /**
* The native on-device age signals for the region the user is currently in, * The native on-device age signals for the region the user is currently in,
* if they've granted access there. Already resolved from the region-keyed * if they've granted access there. Already resolved from the region-keyed
@@ -739,6 +753,7 @@ const AgeAssuranceServerDataContext = createContext<AgeAssuranceServerData>({
declaredAge: undefined, declaredAge: undefined,
birthdate: undefined, birthdate: undefined,
}, },
otherRequiredDataStatus: 'pending',
deviceSignals: undefined, deviceSignals: undefined,
}) })
export function useAgeAssuranceServerDataContext() { export function useAgeAssuranceServerDataContext() {
@@ -752,7 +767,18 @@ export function AgeAssuranceServerDataProvider({
const {data: config} = useConfigQuery() const {data: config} = useConfigQuery()
const serverState = useServerStateQuery() const serverState = useServerStateQuery()
const {state, metadata} = serverState.data || {} const {state, metadata} = serverState.data || {}
const {data} = useOtherRequiredDataQuery() const {data, errorUpdatedAt, status} = useOtherRequiredDataQuery()
/*
* A data-less query returns to `pending` and clears `error` while refetching,
* but retains `errorUpdatedAt`. Keep the error screen mounted until data
* loads successfully.
*/
const otherRequiredDataStatus: OtherRequiredDataStatus =
data !== undefined
? 'success'
: status === 'error' || errorUpdatedAt > 0
? 'error'
: 'pending'
// `select` resolves the cached region-keyed map to the current region. // `select` resolves the cached region-keyed map to the current region.
const {data: deviceSignals} = useDeviceSignalsQuery() const {data: deviceSignals} = useDeviceSignalsQuery()
const ctx = useMemo( const ctx = useMemo(
@@ -767,9 +793,10 @@ export function AgeAssuranceServerDataProvider({
: undefined, : undefined,
birthdate: data?.birthdate, birthdate: data?.birthdate,
}, },
otherRequiredDataStatus,
deviceSignals, deviceSignals,
}), }),
[config, state, data, metadata, deviceSignals], [config, state, data, metadata, otherRequiredDataStatus, deviceSignals],
) )
return ( return (
<AgeAssuranceServerDataContext.Provider value={ctx}> <AgeAssuranceServerDataContext.Provider value={ctx}>
+85
View File
@@ -0,0 +1,85 @@
import {computeAgeAssuranceState} from '#/ageAssurance/state'
import {AgeAssuranceAccess, AgeAssuranceStatus} from '#/ageAssurance/types'
jest.mock('#/ageAssurance/data', () => ({}))
jest.mock('#/ageAssurance/logger', () => ({
logger: {
debug: jest.fn(),
warn: jest.fn(),
},
}))
jest.mock('#/state/session', () => ({}))
const geolocation = {
countryCode: undefined,
regionCode: undefined,
}
describe('computeAgeAssuranceState', () => {
it('computes access while required account data is pending', () => {
expect(
computeAgeAssuranceState({
hasSession: true,
geolocation,
config: {regions: []},
otherRequiredDataStatus: 'pending',
}),
).toMatchObject({
status: AgeAssuranceStatus.Unknown,
access: AgeAssuranceAccess.None,
})
})
it('denies access when required account data fails', () => {
expect(
computeAgeAssuranceState({
hasSession: true,
geolocation,
config: {regions: []},
otherRequiredDataStatus: 'error',
}),
).toEqual({
status: AgeAssuranceStatus.Unknown,
access: AgeAssuranceAccess.None,
error: 'account-data',
})
})
it('computes access after a successful response without a birthdate', () => {
expect(
computeAgeAssuranceState({
hasSession: true,
geolocation,
config: {regions: []},
metadata: {birthdate: undefined},
otherRequiredDataStatus: 'success',
}),
).toMatchObject({
status: AgeAssuranceStatus.Unknown,
access: AgeAssuranceAccess.None,
})
})
it('preserves authoritative terminal server state without account data', () => {
expect(
computeAgeAssuranceState({
hasSession: true,
geolocation: {countryCode: 'AA', regionCode: undefined},
config: {
regions: [
{
countryCode: 'AA',
minAccessAge: 13,
rules: [],
},
],
},
state: {status: 'blocked', access: 'none'},
otherRequiredDataStatus: 'error',
}),
).toMatchObject({
status: AgeAssuranceStatus.Blocked,
access: AgeAssuranceAccess.None,
})
})
})
+24 -3
View File
@@ -9,6 +9,7 @@ import {
getDeviceSignalsFromCacheForRegion, getDeviceSignalsFromCacheForRegion,
getOtherRequiredDataFromCache, getOtherRequiredDataFromCache,
getServerStateFromCache, getServerStateFromCache,
type OtherRequiredDataStatus,
useAgeAssuranceServerDataContext, useAgeAssuranceServerDataContext,
} from '#/ageAssurance/data' } from '#/ageAssurance/data'
import {logger} from '#/ageAssurance/logger' import {logger} from '#/ageAssurance/logger'
@@ -35,12 +36,13 @@ import {device} from '#/storage'
* server state before computing access based on AA config from the server + * server state before computing access based on AA config from the server +
* geolocation and other data. * geolocation and other data.
*/ */
function computeAgeAssuranceState({ export function computeAgeAssuranceState({
hasSession, hasSession,
geolocation, geolocation,
config, config,
state, state,
metadata, metadata,
otherRequiredDataStatus,
deviceSignals, deviceSignals,
}: { }: {
hasSession: boolean hasSession: boolean
@@ -48,6 +50,7 @@ function computeAgeAssuranceState({
config?: app.bsky.ageassurance.defs.Config config?: app.bsky.ageassurance.defs.Config
state?: app.bsky.ageassurance.defs.State state?: app.bsky.ageassurance.defs.State
metadata?: AgeAssuranceMetadata metadata?: AgeAssuranceMetadata
otherRequiredDataStatus: OtherRequiredDataStatus
deviceSignals?: AgeRange.AgeRangeResponse deviceSignals?: AgeRange.AgeRangeResponse
}) { }) {
/** /**
@@ -93,6 +96,14 @@ function computeAgeAssuranceState({
} }
} }
if (otherRequiredDataStatus === 'error') {
return {
status: AgeAssuranceStatus.Unknown,
access: AgeAssuranceAccess.None,
error: 'account-data' as const,
}
}
/* /*
* Otherwise, we need to compute the access based on the latest data. For * Otherwise, we need to compute the access based on the latest data. For
* accounts with an accurate birthdate, our default fallback rules should * accounts with an accurate birthdate, our default fallback rules should
@@ -177,6 +188,7 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) {
geolocation, geolocation,
state: state.state, state: state.state,
metadata, metadata,
otherRequiredDataStatus: 'success',
deviceSignals, deviceSignals,
}) })
@@ -194,7 +206,7 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) {
export function useAgeAssuranceState(): AgeAssuranceState { export function useAgeAssuranceState(): AgeAssuranceState {
const {hasSession} = useSession() const {hasSession} = useSession()
const geolocation = useGeolocation() const geolocation = useGeolocation()
const {config, state, metadata, deviceSignals} = const {config, state, metadata, otherRequiredDataStatus, deviceSignals} =
useAgeAssuranceServerDataContext() useAgeAssuranceServerDataContext()
return useMemo( return useMemo(
@@ -205,9 +217,18 @@ export function useAgeAssuranceState(): AgeAssuranceState {
geolocation, geolocation,
state, state,
metadata, metadata,
otherRequiredDataStatus,
deviceSignals, deviceSignals,
}), }),
[hasSession, geolocation, config, state, metadata, deviceSignals], [
hasSession,
geolocation,
config,
state,
metadata,
otherRequiredDataStatus,
deviceSignals,
],
) )
} }
+2 -1
View File
@@ -43,7 +43,8 @@ export type AgeAssuranceState = {
lastInitiatedAt?: string lastInitiatedAt?: string
status: AgeAssuranceStatus status: AgeAssuranceStatus
access: AgeAssuranceAccess access: AgeAssuranceAccess
error?: 'config' // maybe other specific cases in the future isLoading?: boolean
error?: 'config' | 'account-data'
} }
export type AgeAssuranceFlags = { export type AgeAssuranceFlags = {
+1
View File
@@ -44,6 +44,7 @@ export type Events = {
| 'SignupQueued' | 'SignupQueued'
| 'Deactivated' | 'Deactivated'
| 'Takendown' | 'Takendown'
| 'AgeAssuranceDataUnavailableScreen'
| 'AgeAssuranceNoAccessScreen' | 'AgeAssuranceNoAccessScreen'
scope: 'current' | 'every' scope: 'current' | 'every'
} }
+56 -10
View File
@@ -3,27 +3,38 @@ import {Trans, useLingui} from '@lingui/react/macro'
import {useGoBack} from '#/lib/hooks/useGoBack' import {useGoBack} from '#/lib/hooks/useGoBack'
import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {type Props as SVGIconProps} from '#/components/icons/common'
import * as Layout from '#/components/Layout' import * as Layout from '#/components/Layout'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
export function Error({ export function Error({
icon: Icon,
title, title,
message, message,
onRetry, onRetry,
onGoBack, onGoBack,
hideBackButton, hideBackButton,
secondaryAction,
isRetrying,
}: { }: {
icon?: React.ComponentType<SVGIconProps>
title?: string title?: string
message?: string message?: string
onRetry?: () => unknown onRetry?: () => unknown
onGoBack?: () => unknown onGoBack?: () => unknown
hideBackButton?: boolean hideBackButton?: boolean
isRetrying?: boolean
secondaryAction?: {
label: string
accessibilityLabel?: string
onPress: () => unknown
}
}) { }) {
const {t: l} = useLingui() const {t: l} = useLingui()
const t = useTheme() const t = useTheme()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const goBack = useGoBack(onGoBack)
return ( return (
<Layout.Center <Layout.Center
@@ -35,8 +46,11 @@ export function Error({
t.atoms.border_contrast_low, t.atoms.border_contrast_low,
{paddingTop: 175, paddingBottom: 110}, {paddingTop: 175, paddingBottom: 110},
]}> ]}>
<View style={[a.w_full, a.align_center, a.gap_lg]}> <View style={[a.w_full, a.align_center, a.gap_lg, a.px_md]}>
<Text style={[a.font_semi_bold, a.text_3xl]}>{title}</Text> {Icon && <Icon size="4xl" fill={t.atoms.text_contrast_medium.color} />}
<Text style={[a.font_semi_bold, a.text_3xl, a.text_center]}>
{title}
</Text>
<Text <Text
style={[ style={[
a.text_md, a.text_md,
@@ -51,29 +65,61 @@ export function Error({
<View style={[a.gap_md, gtMobile ? {width: 350} : [a.w_full, a.px_lg]]}> <View style={[a.gap_md, gtMobile ? {width: 350} : [a.w_full, a.px_lg]]}>
{onRetry && ( {onRetry && (
<Button <Button
variant="solid"
color="primary" color="primary"
label={l`Press to retry`} label={l`Press to retry`}
onPress={onRetry} onPress={onRetry}
disabled={isRetrying}
size="large"> size="large">
<ButtonText> <ButtonText>
<Trans>Retry</Trans> <Trans>Retry</Trans>
</ButtonText> </ButtonText>
{isRetrying && <ButtonIcon icon={Loader} />}
</Button> </Button>
)} )}
{!hideBackButton && ( {!hideBackButton && secondaryAction ? (
<Button
color={onRetry ? 'secondary' : 'primary'}
label={secondaryAction.accessibilityLabel ?? secondaryAction.label}
onPress={secondaryAction.onPress}
disabled={isRetrying}
size="large">
<ButtonText>{secondaryAction.label}</ButtonText>
</Button>
) : !hideBackButton ? (
<GoBackButton
hasRetry={Boolean(onRetry)}
isRetrying={isRetrying}
onGoBack={onGoBack}
/>
) : null}
</View>
</Layout.Center>
)
}
function GoBackButton({
hasRetry,
isRetrying,
onGoBack,
}: {
hasRetry: boolean
isRetrying?: boolean
onGoBack?: () => unknown
}) {
const {t: l} = useLingui()
const goBack = useGoBack(onGoBack)
return (
<Button <Button
variant="solid" variant="solid"
color={onRetry ? 'secondary' : 'primary'} color={hasRetry ? 'secondary' : 'primary'}
label={l`Return to previous page`} label={l`Return to previous page`}
onPress={goBack} onPress={goBack}
disabled={isRetrying}
size="large"> size="large">
<ButtonText> <ButtonText>
<Trans>Go Back</Trans> <Trans>Go Back</Trans>
</ButtonText> </ButtonText>
</Button> </Button>
)}
</View>
</Layout.Center>
) )
} }
+8
View File
@@ -0,0 +1,8 @@
import {isRetryableRequestError} from '#/lib/async/retry'
describe('retry', () => {
it('identifies retryable request errors', () => {
expect(isRetryableRequestError(new TypeError('Failed to fetch'))).toBe(true)
expect(isRetryableRequestError(new Error('Invalid request'))).toBe(false)
})
})
+5 -1
View File
@@ -1,5 +1,9 @@
import {timeout} from '#/lib/async/timeout' import {timeout} from '#/lib/async/timeout'
import {isNetworkError} from '#/lib/strings/errors' import {isNetworkError, shouldRetryError} from '#/lib/strings/errors'
export function isRetryableRequestError(error: unknown) {
return isNetworkError(error) || shouldRetryError(error)
}
export async function retry<P>( export async function retry<P>(
retries: number, retries: number,
+4 -1
View File
@@ -40,6 +40,7 @@ import {
} from '#/components/PolicyUpdateOverlay' } from '#/components/PolicyUpdateOverlay'
import {Outlet as PortalOutlet} from '#/components/Portal' import {Outlet as PortalOutlet} from '#/components/Portal'
import {useAgeAssurance} from '#/ageAssurance' import {useAgeAssurance} from '#/ageAssurance'
import {DataUnavailableScreen} from '#/ageAssurance/components/DataUnavailableScreen'
import {NoAccessScreen} from '#/ageAssurance/components/NoAccessScreen' import {NoAccessScreen} from '#/ageAssurance/components/NoAccessScreen'
import {RedirectOverlay} from '#/ageAssurance/components/RedirectOverlay' import {RedirectOverlay} from '#/ageAssurance/components/RedirectOverlay'
import {PassiveAnalytics} from '#/analytics/PassiveAnalytics' import {PassiveAnalytics} from '#/analytics/PassiveAnalytics'
@@ -245,7 +246,9 @@ export function Shell() {
<Deactivated /> <Deactivated />
) : ( ) : (
<> <>
{aa.state.access === aa.Access.None ? ( {aa.state.error === 'account-data' ? (
<DataUnavailableScreen />
) : aa.state.access === aa.Access.None ? (
<NoAccessScreen /> <NoAccessScreen />
) : ( ) : (
<RoutesContainer> <RoutesContainer>
+4 -1
View File
@@ -30,6 +30,7 @@ import {
import {Outlet as PortalOutlet} from '#/components/Portal' import {Outlet as PortalOutlet} from '#/components/Portal'
import {WelcomeModal} from '#/components/WelcomeModal' import {WelcomeModal} from '#/components/WelcomeModal'
import {useAgeAssurance} from '#/ageAssurance' import {useAgeAssurance} from '#/ageAssurance'
import {DataUnavailableScreen} from '#/ageAssurance/components/DataUnavailableScreen'
import {NoAccessScreen} from '#/ageAssurance/components/NoAccessScreen' import {NoAccessScreen} from '#/ageAssurance/components/NoAccessScreen'
import {RedirectOverlay} from '#/ageAssurance/components/RedirectOverlay' import {RedirectOverlay} from '#/ageAssurance/components/RedirectOverlay'
import {PassiveAnalytics} from '#/analytics/PassiveAnalytics' import {PassiveAnalytics} from '#/analytics/PassiveAnalytics'
@@ -167,7 +168,9 @@ export function Shell() {
<Deactivated /> <Deactivated />
) : ( ) : (
<> <>
{aa.state.access === aa.Access.None ? ( {aa.state.error === 'account-data' ? (
<DataUnavailableScreen />
) : aa.state.access === aa.Access.None ? (
<NoAccessScreen /> <NoAccessScreen />
) : ( ) : (
<RoutesContainer> <RoutesContainer>