From f113824af973abb8977bbd92cd8ae45ed69da3bb Mon Sep 17 00:00:00 2001 From: Jwalin Shah Date: Wed, 6 May 2026 23:10:34 -0700 Subject: [PATCH] Fix core-state poll logs and rewards timeout UX --- app/src/providers/CoreStateProvider.tsx | 18 ++++++-- .../__tests__/CoreStateProvider.test.tsx | 13 +++++- .../services/api/__tests__/rewardsApi.test.ts | 21 ++++++++- app/src/services/api/rewardsApi.ts | 45 ++++++++++++++++--- 4 files changed, 84 insertions(+), 13 deletions(-) diff --git a/app/src/providers/CoreStateProvider.tsx b/app/src/providers/CoreStateProvider.tsx index 7fc6b69f3..de8699327 100644 --- a/app/src/providers/CoreStateProvider.tsx +++ b/app/src/providers/CoreStateProvider.tsx @@ -45,6 +45,14 @@ const log = debugFactory('core-state'); const POLL_MS = 2000; const MAX_BOOTSTRAP_RETRIES = 5; +export function shouldWarnForBootstrapFailure(failureCount: number): boolean { + return ( + failureCount === 1 || + failureCount === MAX_BOOTSTRAP_RETRIES || + (failureCount > MAX_BOOTSTRAP_RETRIES && failureCount % MAX_BOOTSTRAP_RETRIES === 0) + ); +} + /** Extract only non-sensitive fields from an RPC/fetch error. */ function sanitizeError(error: unknown): { message?: string; code?: string; status?: number } { if (error instanceof Error) { @@ -377,10 +385,12 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) MAX_BOOTSTRAP_RETRIES, safe ); - console.warn( - `[core-state] poll failed (attempt ${bootstrapFailCountRef.current}/${MAX_BOOTSTRAP_RETRIES}):`, - safe - ); + if (shouldWarnForBootstrapFailure(bootstrapFailCountRef.current)) { + console.warn( + `[core-state] poll failed (attempt ${bootstrapFailCountRef.current}/${MAX_BOOTSTRAP_RETRIES}):`, + safe + ); + } if (bootstrapFailCountRef.current >= MAX_BOOTSTRAP_RETRIES) { commitState(previous => { if (previous.isBootstrapping) { diff --git a/app/src/providers/__tests__/CoreStateProvider.test.tsx b/app/src/providers/__tests__/CoreStateProvider.test.tsx index 2c460619f..0dd6596d5 100644 --- a/app/src/providers/__tests__/CoreStateProvider.test.tsx +++ b/app/src/providers/__tests__/CoreStateProvider.test.tsx @@ -5,7 +5,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import * as coreStateApi from '../../services/coreStateApi'; import * as tauriCommands from '../../utils/tauriCommands'; import { setCoreStateSnapshot } from '../../lib/coreState/store'; -import CoreStateProvider, { useCoreState } from '../CoreStateProvider'; +import CoreStateProvider, { + shouldWarnForBootstrapFailure, + useCoreState, +} from '../CoreStateProvider'; vi.mock('../../services/coreStateApi'); vi.mock('../../services/analytics', () => ({ syncAnalyticsConsent: vi.fn() })); @@ -216,6 +219,14 @@ describe('CoreStateProvider — identity-change cache clearing', () => { await waitFor(() => expect(screen.getByTestId('ready').textContent).toBe('ready')); }); + it('rate-limits repeated bootstrap poll warnings to useful checkpoints', () => { + const warnedAttempts = Array.from({ length: 12 }, (_, index) => index + 1).filter( + shouldWarnForBootstrapFailure + ); + + expect(warnedAttempts).toEqual([1, 5, 10]); + }); + it('backfills snapshot.currentUser from auth.user when currentUser is missing', async () => { fetchSnapshot.mockResolvedValue( makeSnapshot({ diff --git a/app/src/services/api/__tests__/rewardsApi.test.ts b/app/src/services/api/__tests__/rewardsApi.test.ts index 24ce9f783..24dee76c4 100644 --- a/app/src/services/api/__tests__/rewardsApi.test.ts +++ b/app/src/services/api/__tests__/rewardsApi.test.ts @@ -1,9 +1,13 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { normalizeRewardsSnapshot, rewardsApi } from '../rewardsApi'; vi.mock('../../apiClient', () => ({ apiClient: { get: vi.fn() } })); +beforeEach(() => { + vi.clearAllMocks(); +}); + describe('normalizeRewardsSnapshot', () => { it('normalizes a backend rewards payload', () => { const snapshot = normalizeRewardsSnapshot({ @@ -101,7 +105,7 @@ describe('rewardsApi', () => { const snapshot = await rewardsApi.getMyRewards(); - expect(apiClient.get).toHaveBeenCalledWith('/rewards/me'); + expect(apiClient.get).toHaveBeenCalledWith('/rewards/me', { timeout: 15_000 }); expect(snapshot.discord.membershipStatus).toBe('not_linked'); expect(snapshot.summary.totalCount).toBe(8); }); @@ -118,4 +122,17 @@ describe('rewardsApi', () => { error: 'Rewards service unavailable', }); }); + + it('returns an actionable quiet error when /rewards/me times out', async () => { + const { apiClient } = await import('../../apiClient'); + vi.mocked(apiClient.get).mockRejectedValueOnce({ + success: false, + error: 'Failed to load resource: net::ERR_TIMED_OUT', + }); + + await expect(rewardsApi.getMyRewards()).rejects.toMatchObject({ + success: false, + error: 'Rewards request timed out. Please check your connection and try again.', + }); + }); }); diff --git a/app/src/services/api/rewardsApi.ts b/app/src/services/api/rewardsApi.ts index 31beb52aa..e969528ab 100644 --- a/app/src/services/api/rewardsApi.ts +++ b/app/src/services/api/rewardsApi.ts @@ -2,6 +2,10 @@ import type { ApiResponse } from '../../types/api'; import type { RewardsAchievement, RewardsSnapshot } from '../../types/rewards'; import { apiClient } from '../apiClient'; +const REWARDS_REQUEST_TIMEOUT_MS = 15_000; +const REWARDS_TIMEOUT_MESSAGE = + 'Rewards request timed out. Please check your connection and try again.'; + function asRecord(value: unknown): Record | null { return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) @@ -34,6 +38,24 @@ function asFiniteNumberOrNull(value: unknown): number | null { return null; } +function errorText(error: unknown): string { + if (error instanceof Error) return error.message; + const raw = asRecord(error); + const value = raw?.error ?? raw?.message ?? raw?.code; + return typeof value === 'string' ? value : ''; +} + +function isTimeoutError(error: unknown): boolean { + const text = errorText(error).toLowerCase(); + return ( + text.includes('timed out') || + text.includes('timeout') || + text.includes('err_timed_out') || + text.includes('aborterror') || + text.includes('aborted') + ); +} + function normalizeAchievement(value: unknown): RewardsAchievement { const raw = asRecord(value) ?? {}; const creditAmountUsd = asFiniteNumberOrNull(raw.creditAmountUsd); @@ -106,12 +128,23 @@ export function normalizeRewardsSnapshot(payload: unknown): RewardsSnapshot { export const rewardsApi = { async getMyRewards(): Promise { - const response = await apiClient.get>('/rewards/me'); - if (!response.success) { - throw { - success: false, - error: response.error ?? response.message ?? 'Unable to load rewards', - }; + let response: ApiResponse; + try { + response = await apiClient.get>('/rewards/me', { + timeout: REWARDS_REQUEST_TIMEOUT_MS, + }); + if (!response.success) { + throw { + success: false, + error: response.error ?? response.message ?? 'Unable to load rewards', + }; + } + } catch (error) { + const message = isTimeoutError(error) + ? REWARDS_TIMEOUT_MESSAGE + : errorText(error) || 'Unable to load rewards'; + console.debug('[rewards] backend snapshot unavailable', { message }); + throw { success: false, error: message }; } console.debug('[rewards] loaded backend snapshot', {