Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions app/src/providers/CoreStateProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
13 changes: 12 additions & 1 deletion app/src/providers/__tests__/CoreStateProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() }));
Expand Down Expand Up @@ -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({
Expand Down
21 changes: 19 additions & 2 deletions app/src/services/api/__tests__/rewardsApi.test.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down Expand Up @@ -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);
});
Expand All @@ -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.',
});
});
});
45 changes: 39 additions & 6 deletions app/src/services/api/rewardsApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -106,12 +128,23 @@ export function normalizeRewardsSnapshot(payload: unknown): RewardsSnapshot {

export const rewardsApi = {
async getMyRewards(): Promise<RewardsSnapshot> {
const response = await apiClient.get<ApiResponse<unknown>>('/rewards/me');
if (!response.success) {
throw {
success: false,
error: response.error ?? response.message ?? 'Unable to load rewards',
};
let response: ApiResponse<unknown>;
try {
response = await apiClient.get<ApiResponse<unknown>>('/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', {
Expand Down
Loading