diff --git a/src/entities/Font/api/proxy/proxyFonts.test.ts b/src/entities/Font/api/proxy/proxyFonts.test.ts index f2caf00..10bbaa8 100644 --- a/src/entities/Font/api/proxy/proxyFonts.test.ts +++ b/src/entities/Font/api/proxy/proxyFonts.test.ts @@ -21,6 +21,7 @@ vi.mock('$shared/api/api', () => ({ import { api } from '$shared/api/api'; import { queryClient } from '$shared/api/queryClient'; import { fontKeys } from '$shared/api/queryKeys'; +import { FontResponseError } from '../../lib/errors/errors'; import { fetchFontsByIds, fetchProxyFontById, @@ -86,16 +87,20 @@ describe('proxyFonts', () => { expect(calledUrl).toContain('offset=0'); }); - test('should throw on invalid response (missing fonts array)', async () => { + test('should throw FontResponseError on invalid response (missing fonts array)', async () => { mockApiGet({ total: 0 }); - await expect(fetchProxyFonts()).rejects.toThrow('Proxy API returned invalid response'); + await expect(fetchProxyFonts()).rejects.toSatisfy( + e => e instanceof FontResponseError && e.field === 'response.fonts', + ); }); - test('should throw on null response data', async () => { + test('should throw FontResponseError on null response data', async () => { vi.mocked(api.get).mockResolvedValueOnce({ data: null, status: 200 }); - await expect(fetchProxyFonts()).rejects.toThrow('Proxy API returned invalid response'); + await expect(fetchProxyFonts()).rejects.toSatisfy( + e => e instanceof FontResponseError && e.field === 'response', + ); }); }); diff --git a/src/entities/Font/api/proxy/proxyFonts.ts b/src/entities/Font/api/proxy/proxyFonts.ts index de299dc..5b953de 100644 --- a/src/entities/Font/api/proxy/proxyFonts.ts +++ b/src/entities/Font/api/proxy/proxyFonts.ts @@ -15,6 +15,7 @@ import { queryClient } from '$shared/api/queryClient'; import { fontKeys } from '$shared/api/queryKeys'; import { buildQueryString } from '$shared/lib/utils'; import type { QueryParams } from '$shared/lib/utils'; +import { FontResponseError } from '../../lib/errors/errors'; import type { UnifiedFont } from '../../model/types'; /** @@ -96,11 +97,16 @@ export interface ProxyFontsParams extends QueryParams { /** * Proxy API response * - * Includes pagination metadata alongside font data + * Includes pagination metadata alongside font data. + * + * Contract: `fonts` is always an array — never `null` or omitted, even when + * `total === 0`. Returning `null` on the wire is a backend regression and + * surfaces as FontResponseError (non-retryable) on the client. */ export interface ProxyFontsResponse { /** - * List of font objects returned by the proxy + * List of font objects returned by the proxy. + * Always an array; empty when no matches. */ fonts: UnifiedFont[]; @@ -156,8 +162,11 @@ export async function fetchProxyFonts( const response = await api.get(url); - if (!response.data || !Array.isArray(response.data.fonts)) { - throw new Error('Proxy API returned invalid response'); + if (!response.data) { + throw new FontResponseError('response', response.data); + } + if (!Array.isArray(response.data.fonts)) { + throw new FontResponseError('response.fonts', response.data.fonts); } return response.data; diff --git a/src/entities/Font/lib/errors/errors.ts b/src/entities/Font/lib/errors/errors.ts index 4a49f96..4702c8a 100644 --- a/src/entities/Font/lib/errors/errors.ts +++ b/src/entities/Font/lib/errors/errors.ts @@ -1,3 +1,5 @@ +import { NonRetryableError } from '$shared/api/queryClient'; + /** * Thrown when the network request to the proxy API fails. * Wraps the underlying fetch error (timeout, DNS failure, connection refused, etc.). @@ -12,11 +14,13 @@ export class FontNetworkError extends Error { /** * Thrown when the proxy API returns a response with an unexpected shape. + * Extends NonRetryableError because schema mismatches are not transient — + * retrying will produce the same failure and only delay surfacing the bug. * * @property field - The name of the field that failed validation (e.g. `'response'`, `'response.fonts'`). * @property received - The actual value received at that field, for debugging. */ -export class FontResponseError extends Error { +export class FontResponseError extends NonRetryableError { readonly name = 'FontResponseError'; constructor( diff --git a/src/entities/Font/model/store/fontCatalogStore/fontCatalogStore.svelte.ts b/src/entities/Font/model/store/fontCatalogStore/fontCatalogStore.svelte.ts index aea0ed0..c8eae85 100644 --- a/src/entities/Font/model/store/fontCatalogStore/fontCatalogStore.svelte.ts +++ b/src/entities/Font/model/store/fontCatalogStore/fontCatalogStore.svelte.ts @@ -441,6 +441,11 @@ export class FontCatalogStore { try { response = await fetchProxyFonts(params); } catch (cause) { + // Preserve non-retryable validation errors so the query client doesn't + // burn the retry budget on a deterministic schema mismatch. + if (cause instanceof FontResponseError) { + throw cause; + } throw new FontNetworkError(cause); } diff --git a/src/features/FilterAndSortFonts/api/filters/filters.ts b/src/features/FilterAndSortFonts/api/filters/filters.ts index 98f5de2..ef38be4 100644 --- a/src/features/FilterAndSortFonts/api/filters/filters.ts +++ b/src/features/FilterAndSortFonts/api/filters/filters.ts @@ -9,6 +9,7 @@ import { api } from '$shared/api/api'; import { API_ENDPOINTS } from '$shared/api/endpoints'; +import { NonRetryableError } from '$shared/api/queryClient'; const PROXY_API_URL = API_ENDPOINTS.filters; @@ -37,7 +38,8 @@ export interface FilterMetadata { type: 'enum' | 'string' | 'array'; /** - * Available filter options + * Available filter options. + * Always an array; empty when the group has no options. */ options: FilterOption[]; } @@ -68,11 +70,16 @@ export interface FilterOption { } /** - * Proxy filters API response + * Proxy filters API response. + * + * Contract: `filters` (and each nested `options`) is always an array — never + * `null` or omitted. Wire-level `null` here is a backend regression and + * surfaces as a non-retryable error on the client. */ export interface ProxyFiltersResponse { /** - * Array of filter metadata + * Array of filter metadata. + * Always an array; empty when no filter groups are configured. */ filters: FilterMetadata[]; } @@ -99,7 +106,7 @@ export async function fetchProxyFilters(): Promise { const response = await api.get(PROXY_API_URL); if (!response.data || !Array.isArray(response.data)) { - throw new Error('Proxy API returned invalid response'); + throw new NonRetryableError('Proxy API returned invalid filters response'); } return response.data; diff --git a/src/shared/api/queryClient.ts b/src/shared/api/queryClient.ts index 7f9ddba..8e18293 100644 --- a/src/shared/api/queryClient.ts +++ b/src/shared/api/queryClient.ts @@ -1,5 +1,15 @@ import { QueryClient } from '@tanstack/query-core'; +/** + * Marker base class for errors that retrying will never fix — schema-validation + * failures, unauthorized responses, contract violations, etc. + * + * The queryClient retry handler short-circuits when it sees this; without it, + * a non-transient backend bug pins the UI through the full retry budget + * (default 3× exponential backoff ≈ 7s). + */ +export class NonRetryableError extends Error {} + /** * Data remains fresh for this long after fetch. Stores that override * staleness (e.g. filtered queries) can use 0 to bypass. @@ -51,7 +61,12 @@ export const queryClient = new QueryClient({ * Refetch on mount if data is stale */ refetchOnMount: true, - retry: QUERY_RETRY_COUNT, + retry: (failureCount, error) => { + if (error instanceof NonRetryableError) { + return false; + } + return failureCount < QUERY_RETRY_COUNT; + }, /** * Exponential backoff: 1s, 2s, 4s, 8s... capped at 30s */