diff --git a/app/src/pages/Rewards.tsx b/app/src/pages/Rewards.tsx index 1418a5c59..2077e1ac7 100644 --- a/app/src/pages/Rewards.tsx +++ b/app/src/pages/Rewards.tsx @@ -1,3 +1,4 @@ +import createDebug from 'debug'; import { useCallback, useEffect, useState } from 'react'; import PillTabBar from '../components/PillTabBar'; @@ -10,6 +11,8 @@ import type { RewardsSnapshot } from '../types/rewards'; type RewardsTab = 'referrals' | 'redeem' | 'rewards'; +const log = createDebug('rewards'); + function errorMessage(err: unknown): string { if (err && typeof err === 'object' && 'error' in err && typeof err.error === 'string') { return err.error; @@ -28,20 +31,21 @@ const Rewards = () => { const [error, setError] = useState(null); const loadRewards = useCallback(async (signal?: { cancelled: boolean }) => { - console.debug('[rewards] fetching snapshot'); + log('fetching snapshot'); setIsLoading(true); setError(null); try { const result = await rewardsApi.getMyRewards(); if (signal?.cancelled) return; setSnapshot(result); - console.debug('[rewards] snapshot applied', { - unlockedCount: result.summary.unlockedCount, - totalCount: result.summary.totalCount, - }); + log( + 'snapshot applied unlockedCount=%d totalCount=%d', + result.summary.unlockedCount, + result.summary.totalCount + ); } catch (err) { const message = errorMessage(err); - console.debug('[rewards] snapshot load failed', message); + log('snapshot load failed error=%s', message); if (signal?.cancelled) return; setSnapshot(null); setError(message); @@ -61,12 +65,12 @@ const Rewards = () => { }, [loadRewards]); const handleTabChange = useCallback((next: RewardsTab) => { - console.debug('[rewards] tab changed', { next }); + log('tab changed next=%s', next); setSelectedTab(next); }, []); const handleRetry = useCallback(() => { - console.debug('[rewards] retry requested'); + log('retry requested'); void loadRewards(); }, [loadRewards]); diff --git a/app/src/providers/CoreStateProvider.tsx b/app/src/providers/CoreStateProvider.tsx index 2169f406d..42ca0abca 100644 --- a/app/src/providers/CoreStateProvider.tsx +++ b/app/src/providers/CoreStateProvider.tsx @@ -67,7 +67,7 @@ export function coreStatePollFailureWarningMessage(failureCount: number): string if (failureCount <= 0) { return null; } - if (failureCount <= MAX_BOOTSTRAP_RETRIES) { + if (failureCount === 1) { return `[core-state] bootstrap poll failed (attempt ${failureCount}/${MAX_BOOTSTRAP_RETRIES}):`; } if (failureCount === SUPPRESS_POLL_WARNING_AT) { diff --git a/app/src/providers/__tests__/CoreStateProvider.test.tsx b/app/src/providers/__tests__/CoreStateProvider.test.tsx index c1dfa2483..65b53218a 100644 --- a/app/src/providers/__tests__/CoreStateProvider.test.tsx +++ b/app/src/providers/__tests__/CoreStateProvider.test.tsx @@ -685,14 +685,13 @@ describe('CoreStateProvider — identity-change cache clearing', () => { }); describe('coreStatePollFailureWarningMessage', () => { - it('logs bounded bootstrap failures and one suppression notice', () => { + it('warns once during bootstrap and once when warnings are suppressed', () => { expect(coreStatePollFailureWarningMessage(0)).toBeNull(); expect(coreStatePollFailureWarningMessage(1)).toBe( '[core-state] bootstrap poll failed (attempt 1/5):' ); - expect(coreStatePollFailureWarningMessage(5)).toBe( - '[core-state] bootstrap poll failed (attempt 5/5):' - ); + expect(coreStatePollFailureWarningMessage(2)).toBeNull(); + expect(coreStatePollFailureWarningMessage(5)).toBeNull(); expect(coreStatePollFailureWarningMessage(6)).toBe( '[core-state] bootstrap budget exhausted; continuing with backoff. Suppressing further warnings until recovery:' ); diff --git a/app/src/services/api/__tests__/rewardsApi.test.ts b/app/src/services/api/__tests__/rewardsApi.test.ts index 94d8efba5..ca3599023 100644 --- a/app/src/services/api/__tests__/rewardsApi.test.ts +++ b/app/src/services/api/__tests__/rewardsApi.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import { normalizeRewardsSnapshot, rewardsApi } from '../rewardsApi'; +import { normalizeRewardsApiError, normalizeRewardsSnapshot, rewardsApi } from '../rewardsApi'; vi.mock('../../apiClient', () => ({ apiClient: { get: vi.fn() } })); @@ -118,4 +118,50 @@ describe('rewardsApi', () => { error: 'Rewards service unavailable', }); }); + + it('preserves backend application errors that contain "timeout" without remapping them', async () => { + // A backend response like { success: false, error: 'Session timeout. Please log in again.' } + // must reach the caller unchanged — it must NOT be replaced with the generic network-timeout + // message, because it carries a real signal from the application layer. + const { apiClient } = await import('../../apiClient'); + vi.mocked(apiClient.get).mockResolvedValueOnce({ + success: false, + data: null, + error: 'Session timeout. Please log in again.', + }); + + await expect(rewardsApi.getMyRewards()).rejects.toMatchObject({ + success: false, + error: 'Session timeout. Please log in again.', + }); + }); + + it('normalizes /rewards/me timeouts into a recoverable message', async () => { + const { apiClient } = await import('../../apiClient'); + vi.mocked(apiClient.get).mockRejectedValueOnce({ + success: false, + error: 'Request timed out after 15s', + }); + + await expect(rewardsApi.getMyRewards()).rejects.toMatchObject({ + success: false, + error: 'Rewards sync timed out. Check your connection and try again.', + }); + }); +}); + +describe('normalizeRewardsApiError', () => { + it('keeps useful backend errors intact', () => { + expect(normalizeRewardsApiError({ error: 'Rewards service unavailable' })).toEqual({ + success: false, + error: 'Rewards service unavailable', + }); + }); + + it('maps abort-style timeout errors to a stable retry message', () => { + expect(normalizeRewardsApiError(new DOMException('Aborted', 'AbortError'))).toEqual({ + success: false, + error: 'Rewards sync timed out. Check your connection and try again.', + }); + }); }); diff --git a/app/src/services/api/rewardsApi.ts b/app/src/services/api/rewardsApi.ts index 574ca9f5f..9d30fd430 100644 --- a/app/src/services/api/rewardsApi.ts +++ b/app/src/services/api/rewardsApi.ts @@ -1,8 +1,13 @@ -import type { ApiResponse } from '../../types/api'; +import createDebug from 'debug'; + +import type { ApiError, ApiResponse } from '../../types/api'; import type { RewardsAchievement, RewardsSnapshot } from '../../types/rewards'; import { apiClient } from '../apiClient'; const REWARDS_SNAPSHOT_TIMEOUT_MS = 15_000; +const log = createDebug('rewards:api'); + +export type RewardsApiError = ApiError & { code?: string; status?: number }; function asRecord(value: unknown): Record | null { return value && typeof value === 'object' && !Array.isArray(value) @@ -36,6 +41,43 @@ function asFiniteNumberOrNull(value: unknown): number | null { return null; } +export function normalizeRewardsApiError(error: unknown): RewardsApiError { + const raw = asRecord(error); + const message = + (typeof raw?.error === 'string' && raw.error) || + (typeof raw?.message === 'string' && raw.message) || + (error instanceof Error ? error.message : null) || + 'Unable to load rewards'; + const code = typeof raw?.code === 'string' ? raw.code : undefined; + const status = typeof raw?.status === 'number' ? raw.status : undefined; + const name = + (typeof raw?.name === 'string' && raw.name) || + (error instanceof Error ? error.name : undefined); + const lowerMessage = message.toLowerCase(); + const isTimeout = + lowerMessage.includes('timed out') || + lowerMessage.includes('timeout') || + code === 'ETIMEDOUT' || + code === 'ECONNABORTED' || + name === 'AbortError'; + + if (isTimeout) { + return { + success: false, + error: 'Rewards sync timed out. Check your connection and try again.', + ...(code ? { code } : {}), + ...(status == null ? {} : { status }), + }; + } + + return { + success: false, + error: message, + ...(code ? { code } : {}), + ...(status == null ? {} : { status }), + }; +} + function normalizeAchievement(value: unknown): RewardsAchievement { const raw = asRecord(value) ?? {}; const creditAmountUsd = asFiniteNumberOrNull(raw.creditAmountUsd); @@ -108,21 +150,44 @@ export function normalizeRewardsSnapshot(payload: unknown): RewardsSnapshot { export const rewardsApi = { async getMyRewards(): Promise { - const response = await apiClient.get>('/rewards/me', { - timeout: REWARDS_SNAPSHOT_TIMEOUT_MS, - }); + let response: ApiResponse; + try { + response = await apiClient.get>('/rewards/me', { + timeout: REWARDS_SNAPSHOT_TIMEOUT_MS, + }); + } catch (transportError) { + // Transport-level failure (network error, timeout, abort) — normalize to + // a stable retryable message. String-based timeout heuristics are only + // safe here where the error comes from the HTTP layer, not from backend + // application logic. + const normalized = normalizeRewardsApiError(transportError); + log( + 'snapshot transport failed error=%s code=%s status=%s', + normalized.error, + normalized.code ?? 'none', + normalized.status ?? 'none' + ); + throw normalized; + } + if (!response.success) { - throw { + // Backend application error — preserve the exact message so callers see + // the real signal (e.g. "Session timeout. Please log in again." must not + // be remapped to the generic network-timeout message). + const appError: RewardsApiError = { success: false, error: response.error ?? response.message ?? 'Unable to load rewards', }; + log('snapshot backend error error=%s', appError.error); + throw appError; } - console.debug('[rewards] loaded backend snapshot', { - achievementCount: Array.isArray((response.data as { achievements?: unknown[] })?.achievements) + log( + 'loaded backend snapshot achievementCount=%d', + Array.isArray((response.data as { achievements?: unknown[] })?.achievements) ? (response.data as { achievements: unknown[] }).achievements.length - : 0, - }); + : 0 + ); return normalizeRewardsSnapshot(response.data); }, };