fix(api): mark schema-validation errors as non-retryable

The proxy returned `{fonts: null, total: 0}` for empty results, which
fetchProxyFonts surfaced as a generic Error. fontCatalogStore wrapped it
as FontNetworkError, and TanStack retried 3× with exponential backoff —
pinning the loading skeleton for ~7s before settling on an empty list.

Schema mismatches are deterministic; retrying only delays surfacing the
contract violation.

- shared/api/queryClient: introduce NonRetryableError marker class.
  The default retry handler short-circuits when it sees this so any
  store using the shared client gets fail-fast behavior for free.
- entities/Font/lib/errors: FontResponseError extends NonRetryableError.
- entities/Font/api/proxy/proxyFonts: throw FontResponseError (was a
  bare Error). Document that ProxyFontsResponse.fonts is always an array.
- entities/Font/.../fontCatalogStore.fetchPage: preserve a
  FontResponseError raised lower in the stack instead of re-wrapping
  it as FontNetworkError.
- features/FilterAndSortFonts/api/filters: throw NonRetryableError on
  invalid filters payloads and document the array-never-null contract.
This commit is contained in:
Ilia Mashkov
2026-05-28 21:37:23 +03:00
parent c90a258f6c
commit 7f20f36d0a
6 changed files with 59 additions and 14 deletions
@@ -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',
);
});
});
+13 -4
View File
@@ -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<ProxyFontsResponse>(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;