Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/@accounter_client-4450-dependencies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@accounter/client": patch
---
dependencies updates:
- Added dependency [`@urql/exchange-retry@2.0.0` ↗︎](https://www.npmjs.com/package/@urql/exchange-retry/v/2.0.0) (to `dependencies`)
27 changes: 27 additions & 0 deletions .changeset/urql-retry-and-devtools-exchanges.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@accounter/client': patch
---

Add `retryExchange` for network failures, and the urql devtools exchange in development.

A single network blip was fatal: the operation failed, a toast appeared, and nothing recovered short
of the user navigating again. `retryExchange` now retries on `error.networkError` only — never on
GraphQL errors, which are answers rather than failures and will not change on a second attempt.

Mutations are never retried. That guard is explicit and load-bearing:
`@urql/exchange-retry` does not exclude mutations on its own, and `retryIf`'s return value is the
whole decision, so without `operation.kind !== 'mutation'` a write that failed mid-flight would be
resubmitted. None of our mutations are idempotent.

Placement matters more than it looks. `retryExchange` sits **after** `authExchange` and immediately
before `fetchExchange`, so `authExchange` observes a single settled result rather than each attempt
and a retry can never drive `didAuthError`/`refreshAuth`. Two tests pin the ordering and the
`retryIf` predicate.

`devtoolsExchange` is added first in the chain, behind `import.meta.env.DEV`, so the urql browser
devtools finally attach during development. A production build was checked to confirm it is
tree-shaken out.

The chain also gains a comment marking where `cacheExchange` belongs — between the error handler and
auth — for when normalized caching lands. Nothing occupies that slot today: passing an explicit
`exchanges` array means urql installs no cache of its own.
2 changes: 2 additions & 0 deletions packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"@mui/x-charts": "8.29.3",
"@tanstack/react-table": "9.2.4",
"@urql/exchange-auth": "3.0.0",
"@urql/exchange-retry": "2.0.0",
"chart.js": "4.5.1",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
Expand Down Expand Up @@ -87,6 +88,7 @@
"@types/deep-equal": "1.0.4",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@urql/devtools": "2.0.3",
"@vitejs/plugin-react": "6.1.1",
"autoprefixer": "10.5.5",
"happy-dom": "20.14.3",
Expand Down
66 changes: 64 additions & 2 deletions packages/client/src/__tests__/urql-client.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { ROUTES } from '../router/routes.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const { createClientMock, mapExchangeMock, authExchangeMock, appendHeadersMock } = vi.hoisted(() => {
const {
createClientMock,
mapExchangeMock,
authExchangeMock,
appendHeadersMock,
retryExchangeMock,
} = vi.hoisted(() => {
const appendHeaders = vi.fn((operation: any, headers: Record<string, string>) => ({
...operation,
context: {
Expand All @@ -17,10 +23,20 @@ const { createClientMock, mapExchangeMock, authExchangeMock, appendHeadersMock }
},
}));
return {
createClientMock: vi.fn(() => ({ mockClient: true })),
createClientMock: vi.fn((_options: { url: string; exchanges: unknown[] }) => ({
mockClient: true,
})),
mapExchangeMock: vi.fn(() => ({ mockMapExchange: true })),
authExchangeMock: vi.fn(),
appendHeadersMock: appendHeaders,
retryExchangeMock: vi.fn(
(_options: {
retryIf: (
error: { networkError?: unknown; graphQLErrors?: unknown[] },
operation: { kind: string },
) => boolean;
}) => ({ mockRetryExchange: true }),
),
};
});

Expand Down Expand Up @@ -49,6 +65,14 @@ vi.mock('urql', () => ({
Provider: ({ children }: { children?: unknown }) => children,
}));

vi.mock('@urql/exchange-retry', () => ({
retryExchange: retryExchangeMock,
}));

vi.mock('@urql/devtools', () => ({
devtoolsExchange: { mockDevtoolsExchange: true },
}));

vi.mock('@urql/exchange-auth', () => ({
authExchange: authExchangeMock.mockImplementation(
(
Expand Down Expand Up @@ -365,6 +389,44 @@ describe('URQL auth exchange hardening', () => {
);
});

it('places retryExchange after authExchange and before fetchExchange', async () => {
// Order is the point: authExchange must see one settled result rather than
// each retry attempt, so retries cannot interact with didAuthError /
// refreshAuth. fetchExchange stays last.
await initializeAuth(async () => 'token-123');

const { exchanges } = createClientMock.mock.calls[0][0];
const authIndex = exchanges.findIndex((e: unknown) => e === authExchangeMock.mock.results[0].value);
const retryIndex = exchanges.findIndex((e: unknown) => e === retryExchangeMock.mock.results[0].value);
const fetchIndex = exchanges.findIndex(
(e: unknown) => (e as { mockFetchExchange?: boolean })?.mockFetchExchange === true,
);

expect(authIndex).toBeGreaterThanOrEqual(0);
expect(retryIndex).toBeGreaterThan(authIndex);
expect(fetchIndex).toBeGreaterThan(retryIndex);
});

it('retries network errors but never GraphQL errors', async () => {
await initializeAuth(async () => 'token-123');

const { retryIf } = retryExchangeMock.mock.calls[0][0];

expect(retryIf({ networkError: new Error('offline') }, { kind: 'query' })).toBe(true);
expect(retryIf({ graphQLErrors: [{ message: 'nope' }] }, { kind: 'query' })).toBe(false);
});

it('never retries a mutation, even on a network error', async () => {
// `@urql/exchange-retry` does NOT exclude mutations on its own — `retryIf`
// alone decides. Without this guard a write that failed mid-flight would be
// resubmitted, and none of our mutations are idempotent.
await initializeAuth(async () => 'token-123');

const { retryIf } = retryExchangeMock.mock.calls[0][0];

expect(retryIf({ networkError: new Error('offline') }, { kind: 'mutation' })).toBe(false);
});

it('uses VITE_GRAPHQL_URL for the client endpoint when set', async () => {
vi.stubEnv('VITE_GRAPHQL_URL', 'https://example.test/graphql');

Expand Down
22 changes: 22 additions & 0 deletions packages/client/src/providers/urql.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import {
type Operation,
type OperationContext,
} from 'urql';
import { devtoolsExchange } from '@urql/devtools';
import { authExchange } from '@urql/exchange-auth';
import { retryExchange } from '@urql/exchange-retry';
import { requestInteractiveReauth } from '../lib/reauth-coordinator.js';
import { ROUTES } from '../router/routes.js';
import { handleUrqlError } from './urql-error-handler.js';
Expand Down Expand Up @@ -240,11 +242,18 @@ export function getUrqlClient(): Client {
globalClient = createClient({
url,
exchanges: [
// Dev only, and first so it observes every operation and result. Tree-shaken
// from production builds by the constant condition.
...(import.meta.env.DEV ? [devtoolsExchange] : []),
mapExchange({
onResult(result) {
handleUrqlError(result);
},
}),
// `cacheExchange` belongs here, between the error handler and auth, when
// normalized caching lands. Nothing occupies the slot today: passing an
// explicit `exchanges` array means urql installs no cache of its own.

authExchange(async utils => {
if (!isDevAuthEnabled) {
const initialToken = await getAccessToken();
Expand Down Expand Up @@ -323,6 +332,19 @@ export function getUrqlClient(): Client {
},
};
}),
// After `authExchange`, deliberately: auth then sees a single settled result
// rather than every retry attempt, so a retry can never drive
// `didAuthError`/`refreshAuth`.
//
// The mutation guard is not belt-and-braces. `@urql/exchange-retry` does
// not exclude mutations on its own — `retryIf`'s return value is the whole
// decision — so without it a write that failed mid-flight would be
// resubmitted, and none of ours are idempotent. Queries and subscriptions
// retry on network errors only; a GraphQL error is an answer, not a
// failure, and will not change on a second attempt.
retryExchange({
retryIf: (error, operation) => operation.kind !== 'mutation' && !!error.networkError,
}),
fetchExchange,
],
});
Expand Down
28 changes: 27 additions & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,9 @@ __metadata:
"@types/deep-equal": "npm:1.0.4"
"@types/react": "npm:19.3.0"
"@types/react-dom": "npm:19.3.0"
"@urql/devtools": "npm:2.0.3"
"@urql/exchange-auth": "npm:3.0.0"
"@urql/exchange-retry": "npm:2.0.0"
"@vitejs/plugin-react": "npm:6.1.1"
autoprefixer: "npm:10.5.5"
chart.js: "npm:4.5.1"
Expand Down Expand Up @@ -11208,6 +11210,18 @@ __metadata:
languageName: node
linkType: hard

"@urql/devtools@npm:2.0.3":
version: 2.0.3
resolution: "@urql/devtools@npm:2.0.3"
dependencies:
wonka: "npm:>= 4.0.9"
peerDependencies:
"@urql/core": ">= 1.14.0"
graphql: ">= 0.11.0"
checksum: 10c0/130d2f91b36b2586e68a90732831cf58dcc0765fc860a6e5b6c58ab5ab4b26856c1b5f92ef84539b4ad0348d6dd1bde209416ad2a9b4731387b064ab4d5ed704
languageName: node
linkType: hard

"@urql/exchange-auth@npm:3.0.0":
version: 3.0.0
resolution: "@urql/exchange-auth@npm:3.0.0"
Expand All @@ -11220,6 +11234,18 @@ __metadata:
languageName: node
linkType: hard

"@urql/exchange-retry@npm:2.0.0":
version: 2.0.0
resolution: "@urql/exchange-retry@npm:2.0.0"
dependencies:
"@urql/core": "npm:^6.0.0"
wonka: "npm:^6.3.2"
peerDependencies:
"@urql/core": ^6.0.0
checksum: 10c0/37a4dbea6b158c73521fc58e33c2b73ad4568e221e3e2fce373a5cadb21b93f345b4a3e574646b2b1e3f598db4d6fcf62cb6d21d5abd11a3ca88d39c248894fd
languageName: node
linkType: hard

"@vercel/oidc@npm:3.2.0":
version: 3.2.0
resolution: "@vercel/oidc@npm:3.2.0"
Expand Down Expand Up @@ -26985,7 +27011,7 @@ __metadata:
languageName: node
linkType: hard

"wonka@npm:^6.3.2":
"wonka@npm:>= 4.0.9, wonka@npm:^6.3.2":
version: 6.3.6
resolution: "wonka@npm:6.3.6"
checksum: 10c0/a8887a7766cf9519b4f80b43842fe1b6575a0f5edf397c5a32c267185bd999af9d3c42d91d6d7cd86d3ec89fdc5f8909bb542004d184fcaad794d25e821ff70d
Expand Down
Loading