From 2eab08675ef559c7f51d0dd6ecf4f3c8da699cc7 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Mon, 31 Aug 2026 22:37:04 +0300 Subject: [PATCH] use react query retries for age assurance --- .../components/DataUnavailableScreen.tsx | 23 +++-------- src/ageAssurance/data.tsx | 39 ++++++++++--------- src/lib/async/retry.test.ts | 21 ++-------- src/lib/async/retry.ts | 30 +++----------- src/state/queries/preferences/index.ts | 3 +- 5 files changed, 35 insertions(+), 81 deletions(-) diff --git a/src/ageAssurance/components/DataUnavailableScreen.tsx b/src/ageAssurance/components/DataUnavailableScreen.tsx index 608cd69459..cbc0efd2c8 100644 --- a/src/ageAssurance/components/DataUnavailableScreen.tsx +++ b/src/ageAssurance/components/DataUnavailableScreen.tsx @@ -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 ( void refetch()} + isRetrying={isFetching} secondaryAction={{ label: l`Sign out`, onPress: () => { diff --git a/src/ageAssurance/data.tsx b/src/ageAssurance/data.tsx index 67f7c2f31c..a2069ddc00 100644 --- a/src/ageAssurance/data.tsx +++ b/src/ageAssurance/data.tsx @@ -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( - 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( diff --git a/src/lib/async/retry.test.ts b/src/lib/async/retry.test.ts index e5a3d36158..97a321ec8c 100644 --- a/src/lib/async/retry.test.ts +++ b/src/lib/async/retry.test.ts @@ -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, []>() - .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) }) }) diff --git a/src/lib/async/retry.ts b/src/lib/async/retry.ts index f748dd6646..cb706c61c9 100644 --- a/src/lib/async/retry.ts +++ b/src/lib/async/retry.ts @@ -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

( retries: number, shouldRetry: (err: any) => boolean, action: () => Promise

, - delay?: RetryDelay, + delay?: number, ): Promise

{ 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

( export async function networkRetry

( retries: number, fn: () => Promise

, - delay?: RetryDelay, + delay?: number, ): Promise

{ return retry(retries, isNetworkError, fn, delay) } - -export async function requestRetry

( - retries: number, - fn: () => Promise

, -): Promise

{ - return retry( - retries, - isRetryableRequestError, - fn, - exponentialBackoffRetryDelay, - ) -} diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts index 84ec1c7d13..d94342e40b 100644 --- a/src/state/queries/preferences/index.ts +++ b/src/state/queries/preferences/index.ts @@ -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)