Skip to content

Commit 5fbc94e

Browse files
committed
fix(console): LocalizationFetchProvider retries a transient /me/localization failure
`/auth/me/localization` is served by the environment kernel that owns the session on a multi-tenant host, and a cold one answers 503 + `Retry-After` while it warms (objectstack#4159). A transient failure is a normal part of a cold start there, not an exception. The provider made ONE attempt and `.catch()`-ed into silence, so a single 503 during warm-up left currency and locale unset for the WHOLE session — every money field a plain number, nothing ever trying again, long after the kernel was ready. Silent and permanent, which is why nobody would report it as a bug. I had looked at this file during objectui#3052 and judged it "cosmetic, degrades gracefully, no change needed". That was right at the time and wrong afterwards: #4159 turned 503 from an anomaly into this endpoint's routine answer, and the judgement was never revisited. The asymmetry with MePermissionsProvider — same host, same provenance, same 503, opposite handling — is what surfaced it. PRIMITIVES SHARED, POLICY NOT. "Is this transient", "how long to wait" and the `Retry-After` parsing move from @object-ui/permissions' internal module to @object-ui/types — the lowest package both callers reach — and `PermissionsFetchError` becomes the generic `HttpFetchError`. One definition of transient rather than a second copy free to drift. No behaviour change for MePermissionsProvider. The policies stay separate because the postures are opposite: permissions is FAIL-CLOSED and holds its loading state across the waits (rendering a permissive default early is a real hazard); localization is COSMETIC and keeps rendering children throughout, filling the value in if and when an attempt succeeds. Its budget is also more patient (4 × 1s base vs 3 × 500ms) — nothing waits on it, so it can still be trying when a slow environment finishes warming. Tests: 8 new, where there were none. One pins the POSTURE rather than the behaviour — children must be rendered while the fetch is still failing — because the plausible future regression here is someone "aligning the two providers" and carrying the fail-closed blocking onto a surface that must never block. Verified: console localization 8/8, permissions 124/124 (regression), full objectui suite 8998 passed / 24 skipped, type-check 78/78, changed-file lint 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016gEeLZ4oTeSXG6fKG1r3vd
1 parent 3cb9646 commit 5fbc94e

7 files changed

Lines changed: 270 additions & 33 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"@object-ui/types": minor
3+
"@object-ui/permissions": patch
4+
"@object-ui/console": patch
5+
---
6+
7+
fix(console): `LocalizationFetchProvider` retries a transient `/me/localization` failure instead of degrading for the whole session
8+
9+
`/auth/me/localization` is served by the environment kernel that owns the session
10+
on a multi-tenant host, and a cold one answers `503` + `Retry-After` while it
11+
warms (objectstack#4159). A transient failure is therefore a normal part of a
12+
cold start — not an exception.
13+
14+
The provider made ONE attempt and `.catch()`-ed into silence. So a single 503
15+
during warm-up left currency and locale unset for the **whole session**, silently
16+
and permanently, long after the kernel was ready. Every money field rendered a
17+
plain number and nothing ever tried again.
18+
19+
It now re-attempts a transient failure (`408`, `425`, `429`, `502`, `503`, `504`,
20+
or a thrown fetch), server-stated `Retry-After` first, exponential backoff
21+
otherwise. `401` / `403` / `404` / `500` are real answers about the caller and
22+
still fail on the first attempt.
23+
24+
**It keeps its posture.** This provider is cosmetic, so it renders children
25+
throughout — including mid-retry — and fills the value in if and when an attempt
26+
succeeds. That is the opposite of `MePermissionsProvider`, which is fail-closed
27+
and holds its loading state across the waits. Both are pinned by tests.
28+
29+
The retry PRIMITIVES ("is this transient", "how long to wait", `Retry-After`
30+
parsing) move from `@object-ui/permissions`'s internal module to
31+
`@object-ui/types` — the lowest package both callers can reach — and
32+
`PermissionsFetchError` becomes the generic `HttpFetchError`. One definition of
33+
transient, two policies, rather than a second copy free to drift from the first.
34+
No behaviour change for `MePermissionsProvider`.
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* `/auth/me/localization` answers `503` + `Retry-After` while a cold environment
6+
* kernel warms (objectstack#4159), so a transient failure is a normal part of a
7+
* cold start on a multi-tenant host. This provider used to make ONE attempt and
8+
* `.catch()` into silence, which meant a single 503 during warm-up degraded
9+
* currency and locale for the whole session — silently, permanently, long after
10+
* the kernel was ready.
11+
*
12+
* These pin the recovery AND the posture it must not lose: unlike the permission
13+
* layer, this one is cosmetic, so it keeps rendering children throughout and
14+
* never gates the app on the answer.
15+
*/
16+
17+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
18+
import { render, screen, waitFor } from '@testing-library/react';
19+
import { LocalizationFetchProvider } from './LocalizationFetchProvider';
20+
21+
const ENDPOINT = '/api/v1/auth/me/localization';
22+
23+
const ok = (body: unknown) => ({
24+
ok: true,
25+
status: 200,
26+
headers: new Headers(),
27+
json: async () => body,
28+
});
29+
const fail = (status: number, headers: Record<string, string> = {}) => ({
30+
ok: false,
31+
status,
32+
headers: new Headers(headers),
33+
json: async () => ({}),
34+
});
35+
36+
function renderProvider() {
37+
return render(
38+
<LocalizationFetchProvider endpoint={ENDPOINT}>
39+
<span data-testid="child">child</span>
40+
</LocalizationFetchProvider>,
41+
);
42+
}
43+
44+
describe('LocalizationFetchProvider', () => {
45+
beforeEach(() => { vi.spyOn(globalThis, 'fetch' as never); });
46+
afterEach(() => { vi.restoreAllMocks(); });
47+
48+
it('recovers from a warming 503 instead of degrading for the session', async () => {
49+
(globalThis.fetch as never as ReturnType<typeof vi.fn>)
50+
// `Retry-After: 0` keeps the test timer-free while still exercising the
51+
// server-stated-delay path.
52+
.mockResolvedValueOnce(fail(503, { 'Retry-After': '0' }))
53+
.mockResolvedValueOnce(ok({ authenticated: true, currency: 'CNY', locale: 'zh-CN' }));
54+
55+
renderProvider();
56+
57+
await waitFor(() =>
58+
expect((globalThis.fetch as never as ReturnType<typeof vi.fn>)).toHaveBeenCalledTimes(2),
59+
);
60+
// The point of the retry: the tenant default actually arrives.
61+
expect(screen.getByTestId('child')).toBeTruthy();
62+
});
63+
64+
it('retries a thrown fetch (offline / DNS / aborted) too', async () => {
65+
(globalThis.fetch as never as ReturnType<typeof vi.fn>)
66+
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
67+
.mockResolvedValueOnce(ok({ authenticated: true, currency: 'EUR' }));
68+
69+
renderProvider();
70+
71+
// A thrown fetch carries no `Retry-After`, so this one waits out the real
72+
// exponential backoff (BASE_DELAY_MS) rather than a server-stated 0.
73+
await waitFor(
74+
() => expect((globalThis.fetch as never as ReturnType<typeof vi.fn>)).toHaveBeenCalledTimes(2),
75+
{ timeout: 4000 },
76+
);
77+
}, 10_000);
78+
79+
it.each([401, 403, 404, 500])(
80+
'does NOT retry %i — a real answer about this caller will not change',
81+
async (status) => {
82+
(globalThis.fetch as never as ReturnType<typeof vi.fn>).mockResolvedValue(fail(status));
83+
84+
renderProvider();
85+
86+
// Give any (wrongly scheduled) retry a chance to fire before asserting.
87+
await new Promise((r) => setTimeout(r, 50));
88+
expect((globalThis.fetch as never as ReturnType<typeof vi.fn>)).toHaveBeenCalledTimes(1);
89+
},
90+
);
91+
92+
it('never blocks: children render while the fetch is still failing', async () => {
93+
(globalThis.fetch as never as ReturnType<typeof vi.fn>).mockResolvedValue(
94+
fail(503, { 'Retry-After': '0' }),
95+
);
96+
97+
renderProvider();
98+
99+
// The posture that separates this provider from MePermissionsProvider — it
100+
// is cosmetic, so nothing waits on the answer, not even mid-retry.
101+
expect(screen.getByTestId('child')).toBeTruthy();
102+
await new Promise((r) => setTimeout(r, 30));
103+
expect(screen.getByTestId('child')).toBeTruthy();
104+
});
105+
106+
it('stops after the retry budget rather than hammering forever', async () => {
107+
(globalThis.fetch as never as ReturnType<typeof vi.fn>).mockResolvedValue(
108+
fail(503, { 'Retry-After': '0' }),
109+
);
110+
111+
renderProvider();
112+
113+
// 1 attempt + MAX_RETRIES(4); waitFor settles once the count stops moving.
114+
await waitFor(() =>
115+
expect((globalThis.fetch as never as ReturnType<typeof vi.fn>)).toHaveBeenCalledTimes(5),
116+
);
117+
await new Promise((r) => setTimeout(r, 50));
118+
expect((globalThis.fetch as never as ReturnType<typeof vi.fn>)).toHaveBeenCalledTimes(5);
119+
});
120+
});

apps/console/src/LocalizationFetchProvider.tsx

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,28 @@
77
* Cosmetic, NOT fail-closed: while loading or on error it renders children with
88
* an empty value (no tenant default → renderers show a plain number), so a slow
99
* or missing endpoint never blocks the app.
10+
*
11+
* That posture is why it retries QUIETLY. On a multi-tenant host this endpoint
12+
* is served by the environment kernel that owns the session, and a cold one
13+
* answers `503` + `Retry-After` while it warms (objectstack#4159) — so a
14+
* transient failure is a normal part of a cold start, not an exception. A single
15+
* attempt meant one 503 during warm-up degraded currency and locale for the
16+
* WHOLE session, silently and permanently, long after the kernel was ready.
17+
*
18+
* It shares the "is this transient / how long to wait" primitives with
19+
* `MePermissionsProvider` but not its policy: that one is fail-closed and holds
20+
* its loading state across the waits, this one keeps rendering throughout and
21+
* simply fills the value in if and when an attempt succeeds.
1022
*/
1123
import { useEffect, useState } from 'react';
1224
import { LocalizationProvider, type LocalizationValue } from '@object-ui/i18n';
25+
import {
26+
HttpFetchError,
27+
backoffMs,
28+
isTransientFailure,
29+
retryAfterFrom,
30+
sleep,
31+
} from '@object-ui/types';
1332

1433
interface MeLocalizationResponse {
1534
authenticated?: boolean;
@@ -18,6 +37,16 @@ interface MeLocalizationResponse {
1837
timezone?: string | null;
1938
}
2039

40+
/**
41+
* Attempts for a transient failure, and the base for the exponential backoff.
42+
* Deliberately more patient than the permission layer's: nothing is waiting on
43+
* this, so it can afford to still be trying when a slow environment finishes
44+
* warming, and a user who never sees a currency symbol is better served by a
45+
* late answer than by no answer.
46+
*/
47+
const MAX_RETRIES = 4;
48+
const BASE_DELAY_MS = 1_000;
49+
2150
export function LocalizationFetchProvider({
2251
endpoint,
2352
children,
@@ -29,15 +58,33 @@ export function LocalizationFetchProvider({
2958

3059
useEffect(() => {
3160
let cancelled = false;
32-
fetch(endpoint, { credentials: 'include', headers: { Accept: 'application/json' } })
33-
.then((r) => (r.ok ? (r.json() as Promise<MeLocalizationResponse>) : null))
34-
.then((json) => {
35-
if (cancelled || !json) return;
36-
setValue({ currency: json.currency ?? undefined, locale: json.locale ?? undefined });
37-
})
38-
.catch(() => {
39-
/* cosmetic — leave the empty value, renderers show plain numbers */
40-
});
61+
62+
void (async () => {
63+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
64+
try {
65+
const res = await fetch(endpoint, {
66+
credentials: 'include',
67+
headers: { Accept: 'application/json' },
68+
});
69+
if (!res.ok) throw new HttpFetchError(res.status, retryAfterFrom(res));
70+
71+
const json = (await res.json()) as MeLocalizationResponse;
72+
if (cancelled) return;
73+
setValue({ currency: json.currency ?? undefined, locale: json.locale ?? undefined });
74+
return;
75+
} catch (err) {
76+
// A real answer about this caller (401/403/404/500) will not change on
77+
// a retry, and neither will the last attempt — either way, stop and
78+
// leave the empty value. Cosmetic: renderers show plain numbers.
79+
if (!isTransientFailure(err) || attempt === MAX_RETRIES) return;
80+
81+
const stated = err instanceof HttpFetchError ? err.retryAfterMs : undefined;
82+
await sleep(backoffMs(attempt, BASE_DELAY_MS, stated));
83+
if (cancelled) return;
84+
}
85+
}
86+
})();
87+
4188
return () => {
4289
cancelled = true;
4390
};

packages/permissions/src/MePermissionsProvider.tsx

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,12 @@
88

99
import React, { useEffect, useMemo, useState, useCallback } from 'react';
1010
import {
11-
PermissionsFetchError,
11+
HttpFetchError,
1212
backoffMs,
1313
isTransientFailure,
14-
parseRetryAfterMs,
14+
retryAfterFrom,
1515
sleep,
16-
} from './retry';
16+
} from '@object-ui/types';
1717
import type {
1818
PermissionAction,
1919
PermissionCheckResult,
@@ -74,7 +74,7 @@ export interface MePermissionsProviderProps {
7474
errorFallback?: (err: Error, retry: () => void) => React.ReactNode;
7575
/**
7676
* How many times to re-attempt a TRANSIENT failure before giving up — a
77-
* response that says "not now" (`TRANSIENT_STATUS` in `./retry`) or a network
77+
* response that says "not now" (`TRANSIENT_STATUS` in `@object-ui/types`) or a network
7878
* error. `0` disables retrying.
7979
*
8080
* This exists because "not now" is a real answer from this endpoint. On a
@@ -148,9 +148,10 @@ export function MePermissionsProvider({
148148
headers: { Accept: 'application/json' },
149149
});
150150
if (!res.ok) {
151-
throw new PermissionsFetchError(
151+
throw new HttpFetchError(
152152
res.status,
153-
parseRetryAfterMs(res.headers?.get?.('Retry-After'), Date.now()),
153+
retryAfterFrom(res),
154+
`Permissions endpoint returned ${res.status}`,
154155
);
155156
}
156157
const json = (await res.json()) as MePermissionsResponse;
@@ -168,7 +169,7 @@ export function MePermissionsProvider({
168169
}
169170
// `loading` stays true across the wait, so the fail-closed loading
170171
// state holds and the recovery is invisible to consumers.
171-
const stated = err instanceof PermissionsFetchError ? err.retryAfterMs : undefined;
172+
const stated = err instanceof HttpFetchError ? err.retryAfterMs : undefined;
172173
await sleep(backoffMs(attempt, retryBaseDelayMs, stated));
173174
if (!live()) return;
174175
}

packages/permissions/src/__tests__/MePermissionsProvider.retry.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2222
import { render, screen, waitFor } from '@testing-library/react';
2323
import React from 'react';
2424
import { MePermissionsProvider } from '../MePermissionsProvider';
25-
import { parseRetryAfterMs } from '../retry';
25+
import { parseRetryAfterMs } from '@object-ui/types';
2626
import { usePermissions } from '../usePermissions';
2727

2828
function Probe() {
Lines changed: 37 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,23 @@
77
*/
88

99
/**
10-
* Retry primitives for {@link MePermissionsProvider}'s `/me/permissions` fetch.
10+
* Primitives for retrying a TRANSIENT HTTP failure.
1111
*
12-
* Their own module rather than the component's: they are plain functions with
13-
* plain tests, and a component file that also exports helpers breaks fast
14-
* refresh.
12+
* These answer two questions only — "is this worth re-attempting?" and "how long
13+
* until the next attempt?" — and nothing about what to do while waiting. That is
14+
* deliberate: the two providers that use them have opposite postures.
15+
* `MePermissionsProvider` is FAIL-CLOSED and holds its loading state across the
16+
* waits, because rendering a permissive default early is a real hazard;
17+
* `LocalizationFetchProvider` is COSMETIC and never blocks, because a missing
18+
* currency just renders a plain number. One definition of "transient", two
19+
* policies — rather than one policy forced on both, or two copies of the
20+
* definition drifting apart.
21+
*
22+
* They live in this package because it is the lowest one both callers can reach
23+
* (`@object-ui/types` has no `@object-ui` dependencies of its own).
1524
*/
1625

17-
/** Upper bound on any single backoff wait, so a bad `Retry-After` cannot park the UI. */
26+
/** Upper bound on any single backoff wait, so a bad `Retry-After` cannot park a caller. */
1827
export const MAX_RETRY_DELAY_MS = 30_000;
1928

2029
/**
@@ -24,10 +33,10 @@ export const MAX_RETRY_DELAY_MS = 30_000;
2433
* - `408` / `425` — the request itself did not land.
2534
* - `429` — rate limited; `Retry-After` usually accompanies it.
2635
* - `502` / `503` / `504` — the upstream is absent, warming or timing out.
27-
* `503` is the load-bearing one here: on a multi-tenant host this endpoint is
28-
* served by the environment kernel that owns the session, and the framework
29-
* answers `503` + `Retry-After` while a cold one is still being built
30-
* (objectstack#4159).
36+
* `503` is the load-bearing one: on a multi-tenant host the `/auth/me/*`
37+
* endpoints are served by the environment kernel that owns the session, and
38+
* the framework answers `503` + `Retry-After` while a cold one is still being
39+
* built (objectstack#4159). Both providers see it, so both must survive it.
3140
*
3241
* Deliberately NOT retried: `401` / `403` (a real answer about this caller),
3342
* `404` (no such endpoint — retrying cannot conjure one) and `500` (a genuine
@@ -36,10 +45,14 @@ export const MAX_RETRY_DELAY_MS = 30_000;
3645
export const TRANSIENT_STATUS: ReadonlySet<number> = new Set([408, 425, 429, 502, 503, 504]);
3746

3847
/** An `Error` that also carries the HTTP status it came from. */
39-
export class PermissionsFetchError extends Error {
40-
constructor(readonly status: number, readonly retryAfterMs?: number) {
41-
super(`Permissions endpoint returned ${status}`);
42-
this.name = 'PermissionsFetchError';
48+
export class HttpFetchError extends Error {
49+
constructor(
50+
readonly status: number,
51+
readonly retryAfterMs?: number,
52+
message?: string,
53+
) {
54+
super(message ?? `Endpoint returned ${status}`);
55+
this.name = 'HttpFetchError';
4356
}
4457
}
4558

@@ -49,15 +62,18 @@ export class PermissionsFetchError extends Error {
4962
* never got an answer at all.
5063
*/
5164
export function isTransientFailure(err: unknown): boolean {
52-
return err instanceof PermissionsFetchError ? TRANSIENT_STATUS.has(err.status) : true;
65+
return err instanceof HttpFetchError ? TRANSIENT_STATUS.has(err.status) : true;
5366
}
5467

5568
/**
5669
* `Retry-After` in ms, or `undefined` when absent/unparseable. Accepts both wire
5770
* forms (delta-seconds and an HTTP-date) and clamps to {@link MAX_RETRY_DELAY_MS}
58-
* so a hostile or buggy value cannot park the UI on its loading state for hours.
71+
* so a hostile or buggy value cannot park a caller for hours.
5972
*/
60-
export function parseRetryAfterMs(value: string | null | undefined, nowMs: number): number | undefined {
73+
export function parseRetryAfterMs(
74+
value: string | null | undefined,
75+
nowMs: number,
76+
): number | undefined {
6177
if (!value) return undefined;
6278
const trimmed = value.trim();
6379
if (!trimmed) return undefined;
@@ -81,3 +97,8 @@ export function backoffMs(attempt: number, baseDelayMs: number, statedMs?: numbe
8197

8298
export const sleep = (ms: number): Promise<void> =>
8399
new Promise<void>((resolve) => setTimeout(resolve, ms));
100+
101+
/** Read `Retry-After` off a response without assuming `headers` exists (test doubles often omit it). */
102+
export function retryAfterFrom(res: { headers?: { get?(name: string): string | null } }): number | undefined {
103+
return parseRetryAfterMs(res.headers?.get?.('Retry-After'), Date.now());
104+
}

0 commit comments

Comments
 (0)