use react query retries for age assurance
This commit is contained in:
@@ -1,36 +1,23 @@
|
||||
import {useState} from 'react'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {usePdsClient, useSessionApi} from '#/state/session'
|
||||
import {useSessionApi} from '#/state/session'
|
||||
import {Error} from '#/components/Error'
|
||||
import {EmojiSad_Stroke2_Corner0_Rounded as EmojiSadIcon} from '#/components/icons/Emoji'
|
||||
import {refetchOtherRequiredData} from '#/ageAssurance/data'
|
||||
import {useOtherRequiredDataQuery} from '#/ageAssurance/data'
|
||||
import {IS_WEB} from '#/env'
|
||||
|
||||
export function DataUnavailableScreen() {
|
||||
const {t: l} = useLingui()
|
||||
const {logoutCurrentAccount} = useSessionApi()
|
||||
const accountClient = usePdsClient()
|
||||
const [isRetrying, setIsRetrying] = useState(false)
|
||||
|
||||
const onRetry = async () => {
|
||||
setIsRetrying(true)
|
||||
try {
|
||||
await refetchOtherRequiredData({accountClient})
|
||||
} catch {
|
||||
// The error screen remains mounted so the user can retry again.
|
||||
} finally {
|
||||
setIsRetrying(false)
|
||||
}
|
||||
}
|
||||
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={onRetry}
|
||||
isRetrying={isRetrying}
|
||||
onRetry={() => void refetch()}
|
||||
isRetrying={isFetching}
|
||||
secondaryAction={{
|
||||
label: l`Sign out`,
|
||||
onPress: () => {
|
||||
|
||||
+20
-19
@@ -7,7 +7,7 @@ import {focusManager, QueryClient, useQuery} from '@tanstack/react-query'
|
||||
import {persistQueryClient} from '@tanstack/react-query-persist-client'
|
||||
import debounce from 'lodash.debounce'
|
||||
|
||||
import {networkRetry, requestRetry} from '#/lib/async/retry'
|
||||
import {isRetryableRequestError, networkRetry} from '#/lib/async/retry'
|
||||
import {createPersistedQueryStorage} from '#/lib/persisted-query-storage'
|
||||
import {getAge} from '#/lib/strings/time'
|
||||
import {
|
||||
@@ -348,9 +348,14 @@ export type OtherRequiredData = {
|
||||
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}) {
|
||||
return ['otherRequiredData', did]
|
||||
}
|
||||
|
||||
async function getOtherRequiredData({
|
||||
accountClient,
|
||||
}: {
|
||||
@@ -359,7 +364,7 @@ async function getOtherRequiredData({
|
||||
if (debug.enabled) return debug.resolve(debug.otherRequiredData)
|
||||
const did = accountClient.did
|
||||
const [prefs, actorDeclaration] = await Promise.all([
|
||||
requestRetry(3, () => accountClient.call(getPreferences)),
|
||||
accountClient.call(getPreferences),
|
||||
fetchActorDeclarationRecord({did, client: accountClient}),
|
||||
])
|
||||
const data: OtherRequiredData = {
|
||||
@@ -457,6 +462,7 @@ export async function prefetchOtherRequiredData({
|
||||
try {
|
||||
logger.debug(`prefetchOtherRequiredData: resolving...`)
|
||||
await qc.fetchQuery({
|
||||
...otherRequiredDataRetryOptions,
|
||||
queryKey: qk,
|
||||
queryFn: () => getOtherRequiredData({accountClient}),
|
||||
})
|
||||
@@ -486,32 +492,18 @@ export function usePatchOtherRequiredData() {
|
||||
[currentAccount],
|
||||
)
|
||||
}
|
||||
export async function refetchOtherRequiredData({
|
||||
accountClient,
|
||||
}: {
|
||||
accountClient: Client
|
||||
}) {
|
||||
const did = accountClient.did
|
||||
if (!did) return
|
||||
const data = await getOtherRequiredData({accountClient})
|
||||
qc.setQueryData<OtherRequiredData>(
|
||||
createOtherRequiredDataQueryKey({did}),
|
||||
data,
|
||||
)
|
||||
return data
|
||||
}
|
||||
export function useOtherRequiredDataQuery() {
|
||||
const accountClient = usePdsClient()
|
||||
const did = accountClient.did
|
||||
return useQuery(
|
||||
{
|
||||
...otherRequiredDataRetryOptions,
|
||||
enabled: !!did,
|
||||
initialData: () => {
|
||||
if (!did) return
|
||||
return getOtherRequiredDataFromCache({did})
|
||||
},
|
||||
queryKey: createOtherRequiredDataQueryKey({did: did!}),
|
||||
retry: false,
|
||||
retryOnMount: false,
|
||||
async queryFn() {
|
||||
return getOtherRequiredData({accountClient})
|
||||
@@ -775,9 +767,18 @@ export function AgeAssuranceServerDataProvider({
|
||||
const {data: config} = useConfigQuery()
|
||||
const serverState = useServerStateQuery()
|
||||
const {state, metadata} = serverState.data || {}
|
||||
const {data, status} = 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 ? status : 'success'
|
||||
data !== undefined
|
||||
? 'success'
|
||||
: status === 'error' || errorUpdatedAt > 0
|
||||
? 'error'
|
||||
: 'pending'
|
||||
// `select` resolves the cached region-keyed map to the current region.
|
||||
const {data: deviceSignals} = useDeviceSignalsQuery()
|
||||
const ctx = useMemo(
|
||||
|
||||
@@ -1,21 +1,8 @@
|
||||
import {exponentialBackoffRetryDelay, retry} from '#/lib/async/retry'
|
||||
import {isRetryableRequestError} from '#/lib/async/retry'
|
||||
|
||||
describe('retry', () => {
|
||||
it('calculates capped exponential backoff delays', () => {
|
||||
expect([0, 1, 2, 3, 10].map(exponentialBackoffRetryDelay)).toEqual([
|
||||
1000, 2000, 4000, 8000, 30_000,
|
||||
])
|
||||
})
|
||||
|
||||
it('applies the delay between attempts, but not after the last one', async () => {
|
||||
const action = jest
|
||||
.fn<Promise<string>, []>()
|
||||
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
|
||||
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
|
||||
.mockResolvedValue('ok')
|
||||
const delay = jest.fn(() => 0)
|
||||
|
||||
await expect(retry(3, () => true, action, delay)).resolves.toBe('ok')
|
||||
expect(delay.mock.calls).toEqual([[0], [1]])
|
||||
it('identifies retryable request errors', () => {
|
||||
expect(isRetryableRequestError(new TypeError('Failed to fetch'))).toBe(true)
|
||||
expect(isRetryableRequestError(new Error('Invalid request'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
+5
-25
@@ -1,12 +1,6 @@
|
||||
import {timeout} from '#/lib/async/timeout'
|
||||
import {isNetworkError, shouldRetryError} from '#/lib/strings/errors'
|
||||
|
||||
type RetryDelay = number | ((attempt: number) => number)
|
||||
|
||||
export function exponentialBackoffRetryDelay(attempt: number) {
|
||||
return Math.min(1000 * 2 ** attempt, 30_000)
|
||||
}
|
||||
|
||||
export function isRetryableRequestError(error: unknown) {
|
||||
return isNetworkError(error) || shouldRetryError(error)
|
||||
}
|
||||
@@ -15,21 +9,19 @@ export async function retry<P>(
|
||||
retries: number,
|
||||
shouldRetry: (err: any) => boolean,
|
||||
action: () => Promise<P>,
|
||||
delay?: RetryDelay,
|
||||
delay?: number,
|
||||
): Promise<P> {
|
||||
let lastErr
|
||||
let attempt = 0
|
||||
while (retries > 0) {
|
||||
try {
|
||||
return await action()
|
||||
} catch (e: any) {
|
||||
lastErr = e
|
||||
if (shouldRetry(e)) {
|
||||
if (delay) {
|
||||
await timeout(delay)
|
||||
}
|
||||
retries--
|
||||
if (retries === 0) throw e
|
||||
const delayMs = typeof delay === 'function' ? delay(attempt) : delay
|
||||
if (delayMs) await timeout(delayMs)
|
||||
attempt++
|
||||
continue
|
||||
}
|
||||
throw e
|
||||
@@ -41,19 +33,7 @@ export async function retry<P>(
|
||||
export async function networkRetry<P>(
|
||||
retries: number,
|
||||
fn: () => Promise<P>,
|
||||
delay?: RetryDelay,
|
||||
delay?: number,
|
||||
): Promise<P> {
|
||||
return retry(retries, isNetworkError, fn, delay)
|
||||
}
|
||||
|
||||
export async function requestRetry<P>(
|
||||
retries: number,
|
||||
fn: () => Promise<P>,
|
||||
): Promise<P> {
|
||||
return retry(
|
||||
retries,
|
||||
isRetryableRequestError,
|
||||
fn,
|
||||
exponentialBackoffRetryDelay,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
import {type LabelPreference} from '@bsky/sdk/moderation'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {requestRetry} from '#/lib/async/retry'
|
||||
import {PROD_DEFAULT_FEED} from '#/lib/constants'
|
||||
import {replaceEqualDeep} from '#/lib/functions'
|
||||
import {getAge} from '#/lib/strings/time'
|
||||
@@ -72,7 +71,7 @@ export function usePreferencesQuery() {
|
||||
if (!client.did) {
|
||||
return DEFAULT_LOGGED_OUT_PREFERENCES
|
||||
} else {
|
||||
const res = await requestRetry(3, () => client.call(getPreferences))
|
||||
const res = await client.call(getPreferences)
|
||||
|
||||
const labelerDids = res.moderationPrefs.labelers.map(l => l.did)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user