From f113824af973abb8977bbd92cd8ae45ed69da3bb Mon Sep 17 00:00:00 2001 From: Jwalin Shah Date: Wed, 6 May 2026 23:10:34 -0700 Subject: [PATCH 1/2] 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', { From 3af380b5f01c1b6f46bed0d5852d98d83b267b97 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 13 May 2026 20:14:39 -0700 Subject: [PATCH 2/2] =?UTF-8?q?fix(rewards):=20apply=20CodeRabbit=20review?= =?UTF-8?q?=20fixes=20=E2=80=94=20namespaced=20debug=20logger=20and=20exte?= =?UTF-8?q?nded=20checkpoint=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace console.debug calls in rewardsApi.ts with debugFactory('rewards-api') logger per frontend logging guidelines (addresses @coderabbitai on rewardsApi.ts:131-148) - Extend shouldWarnForBootstrapFailure test from length 12 to 16, expect [1,5,10,15] to validate the recurring every-5 pattern including attempt 15 (addresses @coderabbitai on CoreStateProvider.test.tsx:222-228) - Drop misleading /${MAX_BOOTSTRAP_RETRIES} denominator from rate-limited warn message (addresses @senamakel review) --- app/src/providers/CoreStateProvider.tsx | 2 +- .../__tests__/CoreStateProvider.test.tsx | 4 +- app/src/services/api/rewardsApi.ts | 38 +++++++++---------- 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/app/src/providers/CoreStateProvider.tsx b/app/src/providers/CoreStateProvider.tsx index 5df4416aa..347a3c130 100644 --- a/app/src/providers/CoreStateProvider.tsx +++ b/app/src/providers/CoreStateProvider.tsx @@ -385,7 +385,7 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) ); if (shouldWarnForBootstrapFailure(bootstrapFailCountRef.current)) { console.warn( - `[core-state] poll failed (attempt ${bootstrapFailCountRef.current}/${MAX_BOOTSTRAP_RETRIES}):`, + `[core-state] poll failed (attempt ${bootstrapFailCountRef.current}):`, safe ); } diff --git a/app/src/providers/__tests__/CoreStateProvider.test.tsx b/app/src/providers/__tests__/CoreStateProvider.test.tsx index 58e12bd45..e55ef7df3 100644 --- a/app/src/providers/__tests__/CoreStateProvider.test.tsx +++ b/app/src/providers/__tests__/CoreStateProvider.test.tsx @@ -220,11 +220,11 @@ describe('CoreStateProvider — identity-change cache clearing', () => { }); it('rate-limits repeated bootstrap poll warnings to useful checkpoints', () => { - const warnedAttempts = Array.from({ length: 12 }, (_, index) => index + 1).filter( + const warnedAttempts = Array.from({ length: 16 }, (_, index) => index + 1).filter( shouldWarnForBootstrapFailure ); - expect(warnedAttempts).toEqual([1, 5, 10]); + expect(warnedAttempts).toEqual([1, 5, 10, 15]); }); it('backfills snapshot.currentUser from auth.user when currentUser is missing', async () => { diff --git a/app/src/services/api/rewardsApi.ts b/app/src/services/api/rewardsApi.ts index e969528ab..3cbf71f88 100644 --- a/app/src/services/api/rewardsApi.ts +++ b/app/src/services/api/rewardsApi.ts @@ -1,7 +1,11 @@ +import debugFactory from 'debug'; + import type { ApiResponse } from '../../types/api'; import type { RewardsAchievement, RewardsSnapshot } from '../../types/rewards'; import { apiClient } from '../apiClient'; +const log = debugFactory('rewards-api'); + const REWARDS_REQUEST_TIMEOUT_MS = 15_000; const REWARDS_TIMEOUT_MESSAGE = 'Rewards request timed out. Please check your connection and try again.'; @@ -47,13 +51,7 @@ function errorText(error: unknown): string { 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') - ); + return text.includes('timed out') || text.includes('timeout') || text.includes('err_timed_out'); } function normalizeAchievement(value: unknown): RewardsAchievement { @@ -133,25 +131,27 @@ export const rewardsApi = { 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 }); + log('backend snapshot unavailable message=%s', message); throw { success: false, error: message }; } - console.debug('[rewards] loaded backend snapshot', { - achievementCount: Array.isArray((response.data as { achievements?: unknown[] })?.achievements) - ? (response.data as { achievements: unknown[] }).achievements.length - : 0, - }); + if (!response.success) { + throw { + success: false, + error: response.error ?? response.message ?? 'Unable to load rewards', + }; + } + + const achievementCount = Array.isArray( + (response.data as { achievements?: unknown[] })?.achievements + ) + ? (response.data as { achievements: unknown[] }).achievements.length + : 0; + log('loaded backend snapshot achievementCount=%d', achievementCount); return normalizeRewardsSnapshot(response.data); }, };