@gusnips/react 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"api-client.d.ts","sourceRoot":"","sources":["../src/api-client.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"api-client.d.ts","sourceRoot":"","sources":["../src/api-client.ts"],"names":[],"mappings":"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"}
@@ -1,6 +1,62 @@
1
1
  import { isApiError } from "@gusnips/http";
2
- import { ApiError } from "./api-error.js";
2
+ import { ApiError, parseRetryAfter } from "./api-error.js";
3
3
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
4
+ /**
5
+ * One signal that fires on the caller's abort or on the deadline, whichever comes first.
6
+ *
7
+ * `AbortSignal.timeout` plus `AbortSignal.any` is the obvious way to write this, and it is
8
+ * not portable: React Native replaces the global `AbortSignal` with abort-controller@3, which
9
+ * has neither static — so the phone got `AbortSignal.timeout is not a function` on its very
10
+ * first request. This is the same two rules with an `AbortController`, which every runtime
11
+ * this package targets does have, and it is the ONLY path rather than a fallback: one branch
12
+ * cannot drift from the other.
13
+ *
14
+ * Three details that are the whole reason this is not three lines:
15
+ *
16
+ * - **The already-aborted race.** A caller whose signal fired before we attached would never
17
+ * fire the listener, so the request would go out and run to full term. Checked first.
18
+ * - **A timeout must not look like a cancellation.** `isAbortError` exists so a request nobody
19
+ * is waiting for stays silent; a request that ran out of time is a real failure somebody
20
+ * should see. A bare `controller.abort()` raises an `AbortError` and would be swallowed, so
21
+ * the deadline aborts with a plain `Error` — not a `DOMException`, which `isAbortError` is
22
+ * the one thing that checks for.
23
+ * - **The timer is cleared.** Otherwise every completed request leaves a pending timeout for
24
+ * the rest of its budget, which on a phone is a wake-up nobody asked for.
25
+ */
26
+ function withDeadline(ms, caller) {
27
+ const controller = new AbortController();
28
+ const timer = setTimeout(() => {
29
+ // Named, not typed: callers identify a deadline by `name === "TimeoutError"`, the same
30
+ // way `AbortSignal.timeout` reports it. A `DOMException` would be the faithful copy and is
31
+ // not constructible everywhere this runs.
32
+ const expired = new Error(`Request timed out after ${String(ms)}ms`);
33
+ expired.name = "TimeoutError";
34
+ controller.abort(expired);
35
+ }, ms);
36
+ let detach = () => { };
37
+ if (caller) {
38
+ if (caller.aborted) {
39
+ clearTimeout(timer);
40
+ controller.abort(caller.reason);
41
+ }
42
+ else {
43
+ const onAbort = () => {
44
+ controller.abort(caller.reason);
45
+ };
46
+ caller.addEventListener("abort", onAbort, { once: true });
47
+ detach = () => {
48
+ caller.removeEventListener("abort", onAbort);
49
+ };
50
+ }
51
+ }
52
+ return {
53
+ signal: controller.signal,
54
+ clear: () => {
55
+ clearTimeout(timer);
56
+ detach();
57
+ },
58
+ };
59
+ }
4
60
  export function createApiClient({ baseUrl, session, headers: extraHeaders, onSessionDead, onError, timeoutMs = 30_000, maxRefreshAttempts = 2, refreshRetryDelayMs = 500, signOutTimeoutMs = 3_000, requestIdHeader = "x-request-id", }) {
5
61
  /**
6
62
  * Single-flight refresh.
@@ -51,15 +107,17 @@ export function createApiClient({ baseUrl, session, headers: extraHeaders, onSes
51
107
  const budget = perCall === undefined ? timeoutMs : perCall;
52
108
  if (budget === null)
53
109
  return fetch(`${baseUrl}${path}`, { ...init, signal, headers: buildHeaders(token, headers) });
54
- // `AbortSignal.any` rather than a listener: it also catches the already-aborted race, where
55
- // a caller's signal fired before we ever attached. It keeps the caller's abort and our
56
- // timeout distinguishable at the source, which is what lets `isAbortError` stay honest.
57
- const timeout = AbortSignal.timeout(budget);
58
- return fetch(`${baseUrl}${path}`, {
59
- ...init,
60
- signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
61
- headers: buildHeaders(token, headers),
62
- });
110
+ const deadline = withDeadline(budget, signal);
111
+ try {
112
+ return await fetch(`${baseUrl}${path}`, {
113
+ ...init,
114
+ signal: deadline.signal,
115
+ headers: buildHeaders(token, headers),
116
+ });
117
+ }
118
+ finally {
119
+ deadline.clear();
120
+ }
63
121
  }
64
122
  async function request(path, options = {}) {
65
123
  let res = await send(path, await session.getToken(), options);
@@ -86,6 +144,7 @@ export function createApiClient({ baseUrl, session, headers: extraHeaders, onSes
86
144
  const body = await res.json().catch(() => null);
87
145
  const error = new ApiError(res.status, isApiError(body) ? body.error : null, {
88
146
  requestId: res.headers.get(requestIdHeader) ?? undefined,
147
+ retryAfterSecs: parseRetryAfter(res.headers.get("retry-after")),
89
148
  });
90
149
  // A listener that throws must not become the error the caller sees: the API failure is
91
150
  // the real news, and swallowing it for a bug in a side effect would send everyone
@@ -1 +1 @@
1
- {"version":3,"file":"api-client.js","sourceRoot":"","sources":["../src/api-client.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"api-client.js","sourceRoot":"","sources":["../src/api-client.ts"],"names":[],"mappings":"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"}
@@ -17,6 +17,17 @@ export declare class ApiError extends Error {
17
17
  readonly params: Record<string, string | number> | undefined;
18
18
  readonly details: unknown;
19
19
  readonly requestId: string | undefined;
20
+ /**
21
+ * Seconds until this refusal clears, when the answer said so.
22
+ *
23
+ * Read from the `Retry-After` HEADER, which is where HTTP puts it and where a rate limiter
24
+ * written by anyone puts it. The second migration is why this exists: that API sends
25
+ * `Retry-After: 60` on every 429 and nothing in its body, so the package's only reader —
26
+ * which looked in `details.retryAfterSecs`, the first donor's convention — found nothing, and
27
+ * both the copy and the retry rule were flying blind on the one refusal that states its own
28
+ * expiry. Two conventions, same fact; {@link retryAfterSecs} reads either.
29
+ */
30
+ readonly retryAfterSecs: number | undefined;
20
31
  /**
21
32
  * The client raised this itself to stop a caller while it was already handling the
22
33
  * situation — today, the 401 thrown after a dead session, where the person is already on
@@ -31,8 +42,25 @@ export declare class ApiError extends Error {
31
42
  requestId?: string;
32
43
  expected?: boolean;
33
44
  message?: string;
45
+ retryAfterSecs?: number;
34
46
  });
35
47
  }
48
+ /**
49
+ * Seconds until a refusal clears, from whichever place the API put them.
50
+ *
51
+ * The header first, because it is the standard one and it is what a limiter emits without being
52
+ * asked; then `details.retryAfterSecs`, which one donor's API sends in the body instead. A
53
+ * caller asking "how long" should not have to know which server it is talking to.
54
+ */
55
+ export declare function retryAfterSecs(error: ApiError): number | null;
56
+ /**
57
+ * `Retry-After` in either spec form: delay-seconds, or an HTTP-date.
58
+ *
59
+ * Both forms are real — RFC 9110 allows either and different servers pick differently — and the
60
+ * date one is the half that gets skipped, which turns a stated wait into no wait at all. Clamped
61
+ * at zero: a date already in the past means "now", not a negative delay.
62
+ */
63
+ export declare function parseRetryAfter(value: string | null | undefined): number | undefined;
36
64
  /**
37
65
  * An aborted request — the caller's own `AbortController`, or a navigation that unmounted the
38
66
  * component waiting on it.
@@ -1 +1 @@
1
- {"version":3,"file":"api-error.d.ts","sourceRoot":"","sources":["../src/api-error.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,IAAI,YAAY,EAAE,MAAM,eAAe,CAAC;AAE9D;;;;;;;;;;GAUG;AACH,qBAAa,QAAS,SAAQ,KAAK;IACjC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,CAAC;IAClC,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,CAAC;IACxC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,GAAG,SAAS,CAAC;IAC7D,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,CAAC;IAEvC;;;;;;;;OAQG;IACH,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;gBAGzB,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,YAAY,CAAC,OAAO,CAAC,GAAG,IAAI,EAClC,IAAI,GAAE;QAAE,SAAS,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAO;CAY1E;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAEpD"}
1
+ {"version":3,"file":"api-error.d.ts","sourceRoot":"","sources":["../src/api-error.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,IAAI,YAAY,EAAE,MAAM,eAAe,CAAC;AAE9D;;;;;;;;;;GAUG;AACH,qBAAa,QAAS,SAAQ,KAAK;IACjC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,CAAC;IAClC,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,CAAC;IACxC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,GAAG,SAAS,CAAC;IAC7D,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,CAAC;IACvC;;;;;;;;;OASG;IACH,QAAQ,CAAC,cAAc,EAAE,MAAM,GAAG,SAAS,CAAC;IAE5C;;;;;;;;OAQG;IACH,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;gBAGzB,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,YAAY,CAAC,OAAO,CAAC,GAAG,IAAI,EAClC,IAAI,GAAE;QACJ,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,QAAQ,CAAC,EAAE,OAAO,CAAC;QACnB,OAAO,CAAC,EAAE,MAAM,CAAC;QACjB,cAAc,CAAC,EAAE,MAAM,CAAC;KACpB;CAaT;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,QAAQ,GAAG,MAAM,GAAG,IAAI,CAM7D;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAepF;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAEpD"}
package/dist/api-error.js CHANGED
@@ -16,6 +16,17 @@ export class ApiError extends Error {
16
16
  params;
17
17
  details;
18
18
  requestId;
19
+ /**
20
+ * Seconds until this refusal clears, when the answer said so.
21
+ *
22
+ * Read from the `Retry-After` HEADER, which is where HTTP puts it and where a rate limiter
23
+ * written by anyone puts it. The second migration is why this exists: that API sends
24
+ * `Retry-After: 60` on every 429 and nothing in its body, so the package's only reader —
25
+ * which looked in `details.retryAfterSecs`, the first donor's convention — found nothing, and
26
+ * both the copy and the retry rule were flying blind on the one refusal that states its own
27
+ * expiry. Two conventions, same fact; {@link retryAfterSecs} reads either.
28
+ */
29
+ retryAfterSecs;
19
30
  /**
20
31
  * The client raised this itself to stop a caller while it was already handling the
21
32
  * situation — today, the 401 thrown after a dead session, where the person is already on
@@ -35,9 +46,49 @@ export class ApiError extends Error {
35
46
  this.params = body?.params;
36
47
  this.details = body?.details;
37
48
  this.requestId = opts.requestId;
49
+ this.retryAfterSecs = opts.retryAfterSecs;
38
50
  this.expected = opts.expected ?? false;
39
51
  }
40
52
  }
53
+ /**
54
+ * Seconds until a refusal clears, from whichever place the API put them.
55
+ *
56
+ * The header first, because it is the standard one and it is what a limiter emits without being
57
+ * asked; then `details.retryAfterSecs`, which one donor's API sends in the body instead. A
58
+ * caller asking "how long" should not have to know which server it is talking to.
59
+ */
60
+ export function retryAfterSecs(error) {
61
+ if (error.retryAfterSecs !== undefined)
62
+ return error.retryAfterSecs;
63
+ const details = error.details;
64
+ if (typeof details !== "object" || details === null)
65
+ return null;
66
+ const value = details.retryAfterSecs;
67
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
68
+ }
69
+ /**
70
+ * `Retry-After` in either spec form: delay-seconds, or an HTTP-date.
71
+ *
72
+ * Both forms are real — RFC 9110 allows either and different servers pick differently — and the
73
+ * date one is the half that gets skipped, which turns a stated wait into no wait at all. Clamped
74
+ * at zero: a date already in the past means "now", not a negative delay.
75
+ */
76
+ export function parseRetryAfter(value) {
77
+ const raw = value?.trim();
78
+ if (!raw)
79
+ return undefined;
80
+ // A bare integer is the delay-seconds form, malformed included: `Date.parse` accepts some of
81
+ // them as years, so falling through would read "-5" as a date long past and answer 0 — a
82
+ // wait invented out of a header that never stated one.
83
+ if (/^[+-]?\d+$/.test(raw)) {
84
+ const secs = Number(raw);
85
+ return Number.isSafeInteger(secs) && secs >= 0 ? secs : undefined;
86
+ }
87
+ const at = Date.parse(raw);
88
+ if (Number.isNaN(at))
89
+ return undefined;
90
+ return Math.max(0, Math.round((at - Date.now()) / 1000));
91
+ }
41
92
  /**
42
93
  * An aborted request — the caller's own `AbortController`, or a navigation that unmounted the
43
94
  * component waiting on it.
@@ -1 +1 @@
1
- {"version":3,"file":"api-error.js","sourceRoot":"","sources":["../src/api-error.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;GAUG;AACH,MAAM,OAAO,QAAS,SAAQ,KAAK;IACxB,MAAM,CAAS;IACf,IAAI,CAAqB;IACzB,UAAU,CAAqB;IAC/B,MAAM,CAA8C;IACpD,OAAO,CAAU;IACjB,SAAS,CAAqB;IAEvC;;;;;;;;OAQG;IACM,QAAQ,CAAU;IAE3B,YACE,MAAc,EACd,IAAkC,EAClC,OAAqE,EAAE;QAEvE,KAAK,CAAC,IAAI,CAAC,OAAO,IAAI,IAAI,EAAE,OAAO,IAAI,mBAAmB,MAAM,GAAG,CAAC,CAAC;QACrE,IAAI,CAAC,IAAI,GAAG,UAAU,CAAC;QACvB,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QACrB,IAAI,CAAC,IAAI,GAAG,IAAI,EAAE,IAAI,CAAC;QACvB,IAAI,CAAC,UAAU,GAAG,IAAI,EAAE,UAAU,CAAC;QACnC,IAAI,CAAC,MAAM,GAAG,IAAI,EAAE,MAAM,CAAC;QAC3B,IAAI,CAAC,OAAO,GAAG,IAAI,EAAE,OAAO,CAAC;QAC7B,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC,SAAS,CAAC;QAChC,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC,QAAQ,IAAI,KAAK,CAAC;IACzC,CAAC;CACF;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,YAAY,CAAC,KAAc;IACzC,OAAO,KAAK,YAAY,YAAY,IAAI,KAAK,CAAC,IAAI,KAAK,YAAY,CAAC;AACtE,CAAC"}
1
+ {"version":3,"file":"api-error.js","sourceRoot":"","sources":["../src/api-error.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;GAUG;AACH,MAAM,OAAO,QAAS,SAAQ,KAAK;IACxB,MAAM,CAAS;IACf,IAAI,CAAqB;IACzB,UAAU,CAAqB;IAC/B,MAAM,CAA8C;IACpD,OAAO,CAAU;IACjB,SAAS,CAAqB;IACvC;;;;;;;;;OASG;IACM,cAAc,CAAqB;IAE5C;;;;;;;;OAQG;IACM,QAAQ,CAAU;IAE3B,YACE,MAAc,EACd,IAAkC,EAClC,OAKI,EAAE;QAEN,KAAK,CAAC,IAAI,CAAC,OAAO,IAAI,IAAI,EAAE,OAAO,IAAI,mBAAmB,MAAM,GAAG,CAAC,CAAC;QACrE,IAAI,CAAC,IAAI,GAAG,UAAU,CAAC;QACvB,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QACrB,IAAI,CAAC,IAAI,GAAG,IAAI,EAAE,IAAI,CAAC;QACvB,IAAI,CAAC,UAAU,GAAG,IAAI,EAAE,UAAU,CAAC;QACnC,IAAI,CAAC,MAAM,GAAG,IAAI,EAAE,MAAM,CAAC;QAC3B,IAAI,CAAC,OAAO,GAAG,IAAI,EAAE,OAAO,CAAC;QAC7B,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC,SAAS,CAAC;QAChC,IAAI,CAAC,cAAc,GAAG,IAAI,CAAC,cAAc,CAAC;QAC1C,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC,QAAQ,IAAI,KAAK,CAAC;IACzC,CAAC;CACF;AAED;;;;;;GAMG;AACH,MAAM,UAAU,cAAc,CAAC,KAAe;IAC5C,IAAI,KAAK,CAAC,cAAc,KAAK,SAAS;QAAE,OAAO,KAAK,CAAC,cAAc,CAAC;IACpE,MAAM,OAAO,GAAG,KAAK,CAAC,OAAO,CAAC;IAC9B,IAAI,OAAO,OAAO,KAAK,QAAQ,IAAI,OAAO,KAAK,IAAI;QAAE,OAAO,IAAI,CAAC;IACjE,MAAM,KAAK,GAAI,OAAwC,CAAC,cAAc,CAAC;IACvE,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC;AAC5E,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,eAAe,CAAC,KAAgC;IAC9D,MAAM,GAAG,GAAG,KAAK,EAAE,IAAI,EAAE,CAAC;IAC1B,IAAI,CAAC,GAAG;QAAE,OAAO,SAAS,CAAC;IAE3B,6FAA6F;IAC7F,yFAAyF;IACzF,uDAAuD;IACvD,IAAI,YAAY,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;QAC3B,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC;QACzB,OAAO,MAAM,CAAC,aAAa,CAAC,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS,CAAC;IACpE,CAAC;IAED,MAAM,EAAE,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IAC3B,IAAI,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC;QAAE,OAAO,SAAS,CAAC;IACvC,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,GAAG,IAAI,CAAC,CAAC,CAAC;AAC3D,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,YAAY,CAAC,KAAc;IACzC,OAAO,KAAK,YAAY,YAAY,IAAI,KAAK,CAAC,IAAI,KAAK,YAAY,CAAC;AACtE,CAAC"}
@@ -38,8 +38,6 @@ export type Translate<Key extends string = string> = (key: Key, params?: Record<
38
38
  type WaitName = "waitSeconds" | "waitMinutes" | "waitHours";
39
39
  /** Every name {@link createErrorDescriber} looks up itself. Your catalog carries all of them. */
40
40
  type CopyName = WaitName | "network" | "networkHint" | "unexpected" | "retrySoon";
41
- /** Seconds until a refusal clears, when the envelope carries them. */
42
- export declare function retryAfterSecs(error: ApiError): number | null;
43
41
  /**
44
42
  * "in 4 minutes" / "in 2 hours" — a wait nobody has to convert from seconds.
45
43
  *
@@ -1 +1 @@
1
- {"version":3,"file":"describe-error.d.ts","sourceRoot":"","sources":["../src/describe-error.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAE1C;;;;;;;;;;;;;;;;;GAiBG;AAEH,MAAM,WAAW,cAAc;IAC7B,4CAA4C;IAC5C,KAAK,EAAE,MAAM,CAAC;IACd,+EAA+E;IAC/E,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED;;;;;;;;;GASG;AACH,MAAM,MAAM,SAAS,CAAC,GAAG,SAAS,MAAM,GAAG,MAAM,IAAI,CACnD,GAAG,EAAE,GAAG,EACR,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAC7B,MAAM,CAAC;AAEZ,wFAAwF;AACxF,KAAK,QAAQ,GAAG,aAAa,GAAG,aAAa,GAAG,WAAW,CAAC;AAE5D,iGAAiG;AACjG,KAAK,QAAQ,GAAG,QAAQ,GAAG,SAAS,GAAG,aAAa,GAAG,YAAY,GAAG,WAAW,CAAC;AA8BlF,sEAAsE;AACtE,wBAAgB,cAAc,CAAC,KAAK,EAAE,QAAQ,GAAG,MAAM,GAAG,IAAI,CAK7D;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,MAAM,SAAS,MAAM,EAChD,CAAC,EAAE,SAAS,CAAC,GAAG,MAAM,GAAG,QAAQ,EAAE,CAAC,EACpC,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,GACb,MAAM,CAIR;AAED,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,QAAQ,CAAC;IAChB,2FAA2F;IAC3F,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,sFAAsF;IACtF,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AAED,MAAM,WAAW,qBAAqB,CACpC,MAAM,SAAS,MAAM,EACrB,YAAY,SAAS,MAAM,EAC3B,UAAU,SAAS,MAAM;IAEzB,CAAC,EAAE,SAAS,CAAC,GAAG,MAAM,GAAG,QAAQ,EAAE,GAAG,GAAG,YAAY,GAAG,UAAU,EAAE,CAAC,CAAC;IACtE,sEAAsE;IACtE,UAAU,EAAE,MAAM,CAAC;IACnB;;;;;;;;;OASG;IACH,gBAAgB,EAAE,YAAY,CAAC;IAC/B;;;;;;;;;OASG;IACH,gBAAgB,EAAE,MAAM,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC;IAC9C;;;;;;;;OAQG;IACH,KAAK,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC,CAAC;CAC3C;AAED,MAAM,MAAM,QAAQ,GAAG,CAAC,GAAG,EAAE,YAAY,KAAK,cAAc,CAAC;AAE7D,2BAA2B;AAC3B,wBAAgB,oBAAoB,CAClC,MAAM,SAAS,MAAM,EACrB,YAAY,SAAS,MAAM,EAC3B,UAAU,SAAS,MAAM,EACzB,EACA,CAAC,EACD,UAAU,EACV,gBAAgB,EAChB,gBAAgB,EAChB,KAAU,GACX,EAAE,qBAAqB,CAAC,MAAM,EAAE,YAAY,EAAE,UAAU,CAAC,GAAG,CAAC,KAAK,EAAE,OAAO,KAAK,cAAc,CAkD9F"}
1
+ {"version":3,"file":"describe-error.d.ts","sourceRoot":"","sources":["../src/describe-error.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAkB,MAAM,gBAAgB,CAAC;AAE1D;;;;;;;;;;;;;;;;;GAiBG;AAEH,MAAM,WAAW,cAAc;IAC7B,4CAA4C;IAC5C,KAAK,EAAE,MAAM,CAAC;IACd,+EAA+E;IAC/E,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED;;;;;;;;;GASG;AACH,MAAM,MAAM,SAAS,CAAC,GAAG,SAAS,MAAM,GAAG,MAAM,IAAI,CACnD,GAAG,EAAE,GAAG,EACR,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAC7B,MAAM,CAAC;AAEZ,wFAAwF;AACxF,KAAK,QAAQ,GAAG,aAAa,GAAG,aAAa,GAAG,WAAW,CAAC;AAE5D,iGAAiG;AACjG,KAAK,QAAQ,GAAG,QAAQ,GAAG,SAAS,GAAG,aAAa,GAAG,YAAY,GAAG,WAAW,CAAC;AA8BlF;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,MAAM,SAAS,MAAM,EAChD,CAAC,EAAE,SAAS,CAAC,GAAG,MAAM,GAAG,QAAQ,EAAE,CAAC,EACpC,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,GACb,MAAM,CAIR;AAED,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,QAAQ,CAAC;IAChB,2FAA2F;IAC3F,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,sFAAsF;IACtF,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AAED,MAAM,WAAW,qBAAqB,CACpC,MAAM,SAAS,MAAM,EACrB,YAAY,SAAS,MAAM,EAC3B,UAAU,SAAS,MAAM;IAEzB,CAAC,EAAE,SAAS,CAAC,GAAG,MAAM,GAAG,QAAQ,EAAE,GAAG,GAAG,YAAY,GAAG,UAAU,EAAE,CAAC,CAAC;IACtE,sEAAsE;IACtE,UAAU,EAAE,MAAM,CAAC;IACnB;;;;;;;;;OASG;IACH,gBAAgB,EAAE,YAAY,CAAC;IAC/B;;;;;;;;;OASG;IACH,gBAAgB,EAAE,MAAM,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC;IAC9C;;;;;;;;OAQG;IACH,KAAK,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC,CAAC;CAC3C;AAED,MAAM,MAAM,QAAQ,GAAG,CAAC,GAAG,EAAE,YAAY,KAAK,cAAc,CAAC;AAE7D,2BAA2B;AAC3B,wBAAgB,oBAAoB,CAClC,MAAM,SAAS,MAAM,EACrB,YAAY,SAAS,MAAM,EAC3B,UAAU,SAAS,MAAM,EACzB,EACA,CAAC,EACD,UAAU,EACV,gBAAgB,EAChB,gBAAgB,EAChB,KAAU,GACX,EAAE,qBAAqB,CAAC,MAAM,EAAE,YAAY,EAAE,UAAU,CAAC,GAAG,CAAC,KAAK,EAAE,OAAO,KAAK,cAAc,CAwD9F"}
@@ -1,12 +1,4 @@
1
- import { ApiError } from "./api-error.js";
2
- /** Seconds until a refusal clears, when the envelope carries them. */
3
- export function retryAfterSecs(error) {
4
- const details = error.details;
5
- if (typeof details !== "object" || details === null)
6
- return null;
7
- const value = details.retryAfterSecs;
8
- return typeof value === "number" && Number.isFinite(value) ? value : null;
9
- }
1
+ import { ApiError, retryAfterSecs } from "./api-error.js";
10
2
  /**
11
3
  * "in 4 minutes" / "in 2 hours" — a wait nobody has to convert from seconds.
12
4
  *
@@ -62,8 +54,14 @@ export function createErrorDescriber({ t, copyPrefix, messageKeyPrefix, knownMes
62
54
  // and support can act on it. A 5xx additionally gets "try again shortly", because that one
63
55
  // genuinely does clear on its own — a 4xx does not, and saying so would be a lie that costs
64
56
  // the reader another attempt.
57
+ //
58
+ // No `code` means no envelope, and then `message` is not the server's: it is the client's
59
+ // own `Request failed (502)`, written for a log. A gateway answering with HTML while the API
60
+ // restarts is exactly when that happens, so it reached screens — until the second migration,
61
+ // whose three apps each guarded against that one string by hand.
62
+ const serverWords = error.code === undefined ? "" : error.message;
65
63
  return {
66
- cause: ctx.says ?? (error.message || t(`${copyPrefix}unexpected`)),
64
+ cause: ctx.says ?? (serverWords || t(`${copyPrefix}unexpected`)),
67
65
  hint: error.status >= 500 ? t(`${copyPrefix}retrySoon`) : undefined,
68
66
  };
69
67
  };
@@ -1 +1 @@
1
- {"version":3,"file":"describe-error.js","sourceRoot":"","sources":["../src/describe-error.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"describe-error.js","sourceRoot":"","sources":["../src/describe-error.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AA6E1D;;;;;;;GAOG;AACH,MAAM,UAAU,YAAY,CAC1B,CAAoC,EACpC,IAAY,EACZ,MAAc;IAEd,IAAI,IAAI,GAAG,EAAE;QAAE,OAAO,CAAC,CAAC,GAAG,MAAM,aAAa,EAAE,EAAE,KAAK,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,EAAE,CAAC,CAAC;IAC1F,IAAI,IAAI,GAAG,EAAE,GAAG,EAAE;QAAE,OAAO,CAAC,CAAC,GAAG,MAAM,aAAa,EAAE,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,CAAC,IAAI,GAAG,EAAE,CAAC,EAAE,CAAC,CAAC;IACvF,OAAO,CAAC,CAAC,GAAG,MAAM,WAAW,EAAE,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,CAAC,IAAI,GAAG,IAAI,CAAC,EAAE,CAAC,CAAC;AACrE,CAAC;AAuDD,2BAA2B;AAC3B,MAAM,UAAU,oBAAoB,CAIlC,EACA,CAAC,EACD,UAAU,EACV,gBAAgB,EAChB,gBAAgB,EAChB,KAAK,GAAG,EAAE,GAC8C;IACxD,MAAM,KAAK,GAAG,IAAI,GAAG,CAAS,MAAM,CAAC,IAAI,CAAC,gBAAgB,CAAC,CAAC,CAAC;IAE7D,sFAAsF;IACtF,2FAA2F;IAC3F,8CAA8C;IAC9C,MAAM,GAAG,GAAG,CAAC,IAAY,EAAsB,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IAElE,SAAS,cAAc,CAAC,KAAe;QACrC,MAAM,MAAM,GAAG,CAAC,KAAyB,EAAiB,EAAE;YAC1D,IAAI,KAAK,KAAK,SAAS,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,gBAAgB,CAAC;gBAAE,OAAO,IAAI,CAAC;YAC5E,MAAM,IAAI,GAAG,KAAK,CAAC,KAAK,CAAC,gBAAgB,CAAC,MAAM,CAAC,CAAC;YAClD,OAAO,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,gBAAgB,GAAG,IAAI,EAAE,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;QAC1E,CAAC,CAAC;QACF,wFAAwF;QACxF,4FAA4F;QAC5F,kDAAkD;QAClD,OAAO,CACL,MAAM,CAAC,KAAK,CAAC,UAAU,CAAC;YACxB,CAAC,KAAK,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,gBAAgB,GAAG,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,CAC/E,CAAC;IACJ,CAAC;IAED,OAAO,SAAS,aAAa,CAAC,KAAc;QAC1C,IAAI,CAAC,CAAC,KAAK,YAAY,QAAQ,CAAC,EAAE,CAAC;YACjC,0FAA0F;YAC1F,yCAAyC;YACzC,OAAO,EAAE,KAAK,EAAE,CAAC,CAAC,GAAG,UAAU,SAAS,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC,GAAG,UAAU,aAAa,CAAC,EAAE,CAAC;QACnF,CAAC;QAED,MAAM,QAAQ,GAAG,cAAc,CAAC,KAAK,CAAC,CAAC;QACvC,MAAM,GAAG,GAAiB;YACxB,KAAK;YACL,IAAI,EAAE,cAAc,CAAC,KAAK,CAAC;YAC3B,IAAI,EAAE,QAAQ,KAAK,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,EAAE,QAAQ,EAAE,UAAU,CAAC;YACtE,QAAQ;SACT,CAAC;QAEF,MAAM,GAAG,GAAG,KAAK,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QACrE,IAAI,GAAG;YAAE,OAAO,GAAG,CAAC,GAAG,CAAC,CAAC;QAEzB,yFAAyF;QACzF,2FAA2F;QAC3F,4FAA4F;QAC5F,8BAA8B;QAC9B,EAAE;QACF,0FAA0F;QAC1F,6FAA6F;QAC7F,6FAA6F;QAC7F,iEAAiE;QACjE,MAAM,WAAW,GAAG,KAAK,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC;QAClE,OAAO;YACL,KAAK,EAAE,GAAG,CAAC,IAAI,IAAI,CAAC,WAAW,IAAI,CAAC,CAAC,GAAG,UAAU,YAAY,CAAC,CAAC;YAChE,IAAI,EAAE,KAAK,CAAC,MAAM,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,UAAU,WAAW,CAAC,CAAC,CAAC,CAAC,SAAS;SACpE,CAAC;IACJ,CAAC,CAAC;AACJ,CAAC"}
package/dist/index.d.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  export { cn } from "./cn.ts";
2
- export { ApiError, isAbortError } from "./api-error.ts";
2
+ export { ApiError, isAbortError, retryAfterSecs } from "./api-error.ts";
3
3
  export { createApiClient, type ApiClient, type ApiClientOptions, type RefreshResult, type RequestOptions, type SessionAdapter, } from "./api-client.ts";
4
4
  export { createSseParser, readSseStream, type SseFrame } from "./sse.ts";
5
- export { queryDefaults, shouldRetry, type QueryDefaultsOptions } from "./query.ts";
5
+ export { queryDefaults, retryDelayMs, shouldRetry, type QueryDefaultsOptions } from "./query.ts";
6
6
  export { ErrorBoundary, type ErrorBoundaryFallbackProps, type ErrorBoundaryProps, } from "./error-boundary.tsx";
7
- export { createErrorDescriber, humanizeWait, retryAfterSecs, type DescribedError, type ErrorArm, type ErrorContext, type ErrorDescriberOptions, type Translate, } from "./describe-error.ts";
7
+ export { createErrorDescriber, humanizeWait, type DescribedError, type ErrorArm, type ErrorContext, type ErrorDescriberOptions, type Translate, } from "./describe-error.ts";
8
8
  export { installPreloadErrorHandler, isChunkLoadError, isPreloadHintFailure, reloadOnceForChunkError, } from "./chunk-reload.ts";
9
9
  export { PRERENDERED_ROUTE_ATTR, SHELL_ROUTE } from "./prerender-contract.ts";
10
10
  export { applyBrandVars, i18nInitOptions, type I18nInitOptions } from "./i18n.ts";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AA6BA,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAE7B,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,EACL,eAAe,EACf,KAAK,SAAS,EACd,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,cAAc,EACnB,KAAK,cAAc,GACpB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAE,KAAK,QAAQ,EAAE,MAAM,UAAU,CAAC;AAEzE,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,KAAK,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAEnF,OAAO,EACL,aAAa,EACb,KAAK,0BAA0B,EAC/B,KAAK,kBAAkB,GACxB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,EACZ,cAAc,EACd,KAAK,cAAc,EACnB,KAAK,QAAQ,EACb,KAAK,YAAY,EACjB,KAAK,qBAAqB,EAC1B,KAAK,SAAS,GACf,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,uBAAuB,GACxB,MAAM,mBAAmB,CAAC;AAK3B,OAAO,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE9E,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,KAAK,eAAe,EAAE,MAAM,WAAW,CAAC;AAElF,YAAY,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AA6BA,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAE7B,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AACxE,OAAO,EACL,eAAe,EACf,KAAK,SAAS,EACd,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,cAAc,EACnB,KAAK,cAAc,GACpB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAE,KAAK,QAAQ,EAAE,MAAM,UAAU,CAAC;AAEzE,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,WAAW,EAAE,KAAK,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAEjG,OAAO,EACL,aAAa,EACb,KAAK,0BAA0B,EAC/B,KAAK,kBAAkB,GACxB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,EACZ,KAAK,cAAc,EACnB,KAAK,QAAQ,EACb,KAAK,YAAY,EACjB,KAAK,qBAAqB,EAC1B,KAAK,SAAS,GACf,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,uBAAuB,GACxB,MAAM,mBAAmB,CAAC;AAK3B,OAAO,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE9E,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,KAAK,eAAe,EAAE,MAAM,WAAW,CAAC;AAElF,YAAY,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC"}
package/dist/index.js CHANGED
@@ -27,12 +27,12 @@
27
27
  // Check that claim against `dist/`, never against this file: a type-only re-export looks
28
28
  // identical in source.
29
29
  export { cn } from "./cn.js";
30
- export { ApiError, isAbortError } from "./api-error.js";
30
+ export { ApiError, isAbortError, retryAfterSecs } from "./api-error.js";
31
31
  export { createApiClient, } from "./api-client.js";
32
32
  export { createSseParser, readSseStream } from "./sse.js";
33
- export { queryDefaults, shouldRetry } from "./query.js";
33
+ export { queryDefaults, retryDelayMs, shouldRetry } from "./query.js";
34
34
  export { ErrorBoundary, } from "./error-boundary.js";
35
- export { createErrorDescriber, humanizeWait, retryAfterSecs, } from "./describe-error.js";
35
+ export { createErrorDescriber, humanizeWait, } from "./describe-error.js";
36
36
  export { installPreloadErrorHandler, isChunkLoadError, isPreloadHintFailure, reloadOnceForChunkError, } from "./chunk-reload.js";
37
37
  // The two constants, but NOT `hydrateOrMount` — that one is at `@gusnips/react/hydrate`, because
38
38
  // it is the package's only `react-dom` import. These come from the contract module, which imports
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAClE,EAAE;AACF,2CAA2C;AAC3C,EAAE;AACF,+FAA+F;AAC/F,gGAAgG;AAChG,+FAA+F;AAC/F,iGAAiG;AACjG,0BAA0B;AAC1B,EAAE;AACF,oEAAoE;AACpE,6DAA6D;AAC7D,sEAAsE;AACtE,+DAA+D;AAC/D,EAAE;AACF,6FAA6F;AAC7F,+FAA+F;AAC/F,+FAA+F;AAC/F,+FAA+F;AAC/F,uEAAuE;AACvE,EAAE;AACF,2FAA2F;AAC3F,iGAAiG;AACjG,qFAAqF;AACrF,6FAA6F;AAC7F,iGAAiG;AACjG,yFAAyF;AACzF,uBAAuB;AAEvB,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAE7B,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,EACL,eAAe,GAMhB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAiB,MAAM,UAAU,CAAC;AAEzE,OAAO,EAAE,aAAa,EAAE,WAAW,EAA6B,MAAM,YAAY,CAAC;AAEnF,OAAO,EACL,aAAa,GAGd,MAAM,qBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,EACZ,cAAc,GAMf,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,uBAAuB,GACxB,MAAM,mBAAmB,CAAC;AAE3B,iGAAiG;AACjG,kGAAkG;AAClG,6FAA6F;AAC7F,OAAO,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE9E,OAAO,EAAE,cAAc,EAAE,eAAe,EAAwB,MAAM,WAAW,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAClE,EAAE;AACF,2CAA2C;AAC3C,EAAE;AACF,+FAA+F;AAC/F,gGAAgG;AAChG,+FAA+F;AAC/F,iGAAiG;AACjG,0BAA0B;AAC1B,EAAE;AACF,oEAAoE;AACpE,6DAA6D;AAC7D,sEAAsE;AACtE,+DAA+D;AAC/D,EAAE;AACF,6FAA6F;AAC7F,+FAA+F;AAC/F,+FAA+F;AAC/F,+FAA+F;AAC/F,uEAAuE;AACvE,EAAE;AACF,2FAA2F;AAC3F,iGAAiG;AACjG,qFAAqF;AACrF,6FAA6F;AAC7F,iGAAiG;AACjG,yFAAyF;AACzF,uBAAuB;AAEvB,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAE7B,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AACxE,OAAO,EACL,eAAe,GAMhB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAiB,MAAM,UAAU,CAAC;AAEzE,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,WAAW,EAA6B,MAAM,YAAY,CAAC;AAEjG,OAAO,EACL,aAAa,GAGd,MAAM,qBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,GAMb,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,uBAAuB,GACxB,MAAM,mBAAmB,CAAC;AAE3B,iGAAiG;AACjG,kGAAkG;AAClG,6FAA6F;AAC7F,OAAO,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE9E,OAAO,EAAE,cAAc,EAAE,eAAe,EAAwB,MAAM,WAAW,CAAC"}
package/dist/query.d.ts CHANGED
@@ -8,6 +8,15 @@ export interface QueryDefaultsOptions {
8
8
  durableLimitCodes?: readonly string[];
9
9
  /** How many times to retry a transient failure. Two donors used 2, one used 1. */
10
10
  maxRetries?: number;
11
+ /**
12
+ * Longest stated wait still worth retrying. Default 10 seconds.
13
+ *
14
+ * Only applies when the refusal named its own expiry (`Retry-After`, or `retryAfterSecs` in
15
+ * the body). A burst limit that clears in two seconds is worth sitting out; a monthly ceiling
16
+ * that clears in an hour is an answer, and holding a spinner for it helps nobody — show the
17
+ * wait instead, which is what `humanizeWait` is for.
18
+ */
19
+ maxRetryWaitSecs?: number;
11
20
  staleTime?: number;
12
21
  gcTime?: number;
13
22
  /**
@@ -23,6 +32,12 @@ export interface QueryDefaultsOptions {
23
32
  * Exported on its own because an app with its own QueryClient config still wants this rule, and
24
33
  * because it is the part worth testing.
25
34
  */
26
- export declare function shouldRetry(error: unknown, durableLimitCodes?: readonly string[]): boolean;
27
- export declare function queryDefaults({ durableLimitCodes, maxRetries, staleTime, gcTime, refetchOnWindowFocus, }?: QueryDefaultsOptions): DefaultOptions;
35
+ export declare function shouldRetry(error: unknown, durableLimitCodes?: readonly string[], maxRetryWaitSecs?: number): boolean;
36
+ /**
37
+ * How long before the next attempt: the server's stated wait when there is one, react-query's
38
+ * exponential backoff otherwise, and never less than the backoff — a `Retry-After: 0` is a
39
+ * server saying "immediately", which for a client that just got refused is still too soon.
40
+ */
41
+ export declare function retryDelayMs(attemptIndex: number, error: unknown): number;
42
+ export declare function queryDefaults({ durableLimitCodes, maxRetries, maxRetryWaitSecs, staleTime, gcTime, refetchOnWindowFocus, }?: QueryDefaultsOptions): DefaultOptions;
28
43
  //# sourceMappingURL=query.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"query.d.ts","sourceRoot":"","sources":["../src/query.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAuB5D,MAAM,WAAW,oBAAoB;IACnC;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACtC,kFAAkF;IAClF,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;OAIG;IACH,oBAAoB,CAAC,EAAE,OAAO,CAAC;CAChC;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,EAAE,iBAAiB,GAAE,SAAS,MAAM,EAAO,GAAG,OAAO,CAQ9F;AAED,wBAAgB,aAAa,CAAC,EAC5B,iBAAsB,EACtB,UAAc,EACd,SAAyB,EACzB,MAAuB,EACvB,oBAA4B,GAC7B,GAAE,oBAAyB,GAAG,cAAc,CAc5C"}
1
+ {"version":3,"file":"query.d.ts","sourceRoot":"","sources":["../src/query.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AA+B5D,MAAM,WAAW,oBAAoB;IACnC;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACtC,kFAAkF;IAClF,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;;;;OAOG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;OAIG;IACH,oBAAoB,CAAC,EAAE,OAAO,CAAC;CAChC;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CACzB,KAAK,EAAE,OAAO,EACd,iBAAiB,GAAE,SAAS,MAAM,EAAO,EACzC,gBAAgB,SAA8B,GAC7C,OAAO,CAcT;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,YAAY,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,MAAM,CAKzE;AAED,wBAAgB,aAAa,CAAC,EAC5B,iBAAsB,EACtB,UAAc,EACd,gBAA8C,EAC9C,SAAyB,EACzB,MAAuB,EACvB,oBAA4B,GAC7B,GAAE,oBAAyB,GAAG,cAAc,CAe5C"}
package/dist/query.js CHANGED
@@ -1,4 +1,4 @@
1
- import { ApiError } from "./api-error.js";
1
+ import { ApiError, retryAfterSecs } from "./api-error.js";
2
2
  /**
3
3
  * react-query defaults — the merge of three repos that each got part of this right.
4
4
  *
@@ -13,33 +13,58 @@ import { ApiError } from "./api-error.js";
13
13
  * - one set **`refetchOnReconnect`**, which none of the others had, and which is the single
14
14
  * most useful refetch there is: coming back from a tunnel is exactly when the screen is stale.
15
15
  *
16
+ * The second migration added the fourth rule, which no donor had: **when a 429 says how long,
17
+ * believe it.** Its limiter answers `Retry-After: 60` on a per-minute window, and the default
18
+ * backoff retries at 1s and 2s — two more requests that cannot succeed, both charged against the
19
+ * same limiter, and three seconds added before the screen says anything. So a stated wait is
20
+ * waited out when it is short, and is an ANSWER when it is not.
21
+ *
16
22
  * This is invariant 8.
17
23
  */
18
24
  /** 4xx statuses a second attempt can fix. Everything else in the 4xx family is an answer. */
19
25
  const RETRYABLE_CLIENT_STATUS = new Set([408, 429]);
26
+ const DEFAULT_MAX_RETRY_WAIT_SECS = 10;
20
27
  /**
21
28
  * Should this failure be retried?
22
29
  *
23
30
  * Exported on its own because an app with its own QueryClient config still wants this rule, and
24
31
  * because it is the part worth testing.
25
32
  */
26
- export function shouldRetry(error, durableLimitCodes = []) {
33
+ export function shouldRetry(error, durableLimitCodes = [], maxRetryWaitSecs = DEFAULT_MAX_RETRY_WAIT_SECS) {
27
34
  // No response at all — offline, DNS, a dropped connection. The request never landed, so
28
35
  // nothing about it is an answer, and a second attempt is exactly right.
29
36
  if (!(error instanceof ApiError))
30
37
  return true;
31
38
  if (error.code !== undefined && durableLimitCodes.includes(error.code))
32
39
  return false;
40
+ // It told us when it clears. A wait we are not willing to hold is a refusal, not a hiccup —
41
+ // and it is the same judgement `durableLimitCodes` makes, except the server did the naming.
42
+ const wait = retryAfterSecs(error);
43
+ if (wait !== null && wait > maxRetryWaitSecs)
44
+ return false;
33
45
  if (error.status >= 500)
34
46
  return true;
35
47
  return RETRYABLE_CLIENT_STATUS.has(error.status);
36
48
  }
37
- export function queryDefaults({ durableLimitCodes = [], maxRetries = 2, staleTime = 2 * 60 * 1000, gcTime = 10 * 60 * 1000, refetchOnWindowFocus = false, } = {}) {
49
+ /**
50
+ * How long before the next attempt: the server's stated wait when there is one, react-query's
51
+ * exponential backoff otherwise, and never less than the backoff — a `Retry-After: 0` is a
52
+ * server saying "immediately", which for a client that just got refused is still too soon.
53
+ */
54
+ export function retryDelayMs(attemptIndex, error) {
55
+ const backoff = Math.min(1000 * 2 ** attemptIndex, 30_000);
56
+ if (!(error instanceof ApiError))
57
+ return backoff;
58
+ const wait = retryAfterSecs(error);
59
+ return wait === null ? backoff : Math.max(backoff, wait * 1000);
60
+ }
61
+ export function queryDefaults({ durableLimitCodes = [], maxRetries = 2, maxRetryWaitSecs = DEFAULT_MAX_RETRY_WAIT_SECS, staleTime = 2 * 60 * 1000, gcTime = 10 * 60 * 1000, refetchOnWindowFocus = false, } = {}) {
38
62
  return {
39
63
  queries: {
40
64
  staleTime,
41
65
  gcTime,
42
- retry: (failureCount, error) => failureCount < maxRetries && shouldRetry(error, durableLimitCodes),
66
+ retry: (failureCount, error) => failureCount < maxRetries && shouldRetry(error, durableLimitCodes, maxRetryWaitSecs),
67
+ retryDelay: retryDelayMs,
43
68
  refetchOnWindowFocus,
44
69
  refetchOnReconnect: true,
45
70
  },
package/dist/query.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"query.js","sourceRoot":"","sources":["../src/query.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAE1C;;;;;;;;;;;;;;;GAeG;AAEH,6FAA6F;AAC7F,MAAM,uBAAuB,GAAG,IAAI,GAAG,CAAC,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,CAAC;AAqBpD;;;;;GAKG;AACH,MAAM,UAAU,WAAW,CAAC,KAAc,EAAE,oBAAuC,EAAE;IACnF,wFAAwF;IACxF,wEAAwE;IACxE,IAAI,CAAC,CAAC,KAAK,YAAY,QAAQ,CAAC;QAAE,OAAO,IAAI,CAAC;IAE9C,IAAI,KAAK,CAAC,IAAI,KAAK,SAAS,IAAI,iBAAiB,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,CAAC;QAAE,OAAO,KAAK,CAAC;IACrF,IAAI,KAAK,CAAC,MAAM,IAAI,GAAG;QAAE,OAAO,IAAI,CAAC;IACrC,OAAO,uBAAuB,CAAC,GAAG,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;AACnD,CAAC;AAED,MAAM,UAAU,aAAa,CAAC,EAC5B,iBAAiB,GAAG,EAAE,EACtB,UAAU,GAAG,CAAC,EACd,SAAS,GAAG,CAAC,GAAG,EAAE,GAAG,IAAI,EACzB,MAAM,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,EACvB,oBAAoB,GAAG,KAAK,MACJ,EAAE;IAC1B,OAAO;QACL,OAAO,EAAE;YACP,SAAS;YACT,MAAM;YACN,KAAK,EAAE,CAAC,YAAY,EAAE,KAAK,EAAE,EAAE,CAC7B,YAAY,GAAG,UAAU,IAAI,WAAW,CAAC,KAAK,EAAE,iBAAiB,CAAC;YACpE,oBAAoB;YACpB,kBAAkB,EAAE,IAAI;SACzB;QACD,2FAA2F;QAC3F,yFAAyF;QACzF,SAAS,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE;KAC5B,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"query.js","sourceRoot":"","sources":["../src/query.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAE1D;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,6FAA6F;AAC7F,MAAM,uBAAuB,GAAG,IAAI,GAAG,CAAC,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,CAAC;AAEpD,MAAM,2BAA2B,GAAG,EAAE,CAAC;AA8BvC;;;;;GAKG;AACH,MAAM,UAAU,WAAW,CACzB,KAAc,EACd,oBAAuC,EAAE,EACzC,gBAAgB,GAAG,2BAA2B;IAE9C,wFAAwF;IACxF,wEAAwE;IACxE,IAAI,CAAC,CAAC,KAAK,YAAY,QAAQ,CAAC;QAAE,OAAO,IAAI,CAAC;IAE9C,IAAI,KAAK,CAAC,IAAI,KAAK,SAAS,IAAI,iBAAiB,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,CAAC;QAAE,OAAO,KAAK,CAAC;IAErF,4FAA4F;IAC5F,4FAA4F;IAC5F,MAAM,IAAI,GAAG,cAAc,CAAC,KAAK,CAAC,CAAC;IACnC,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI,GAAG,gBAAgB;QAAE,OAAO,KAAK,CAAC;IAE3D,IAAI,KAAK,CAAC,MAAM,IAAI,GAAG;QAAE,OAAO,IAAI,CAAC;IACrC,OAAO,uBAAuB,CAAC,GAAG,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;AACnD,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,YAAY,CAAC,YAAoB,EAAE,KAAc;IAC/D,MAAM,OAAO,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,GAAG,CAAC,IAAI,YAAY,EAAE,MAAM,CAAC,CAAC;IAC3D,IAAI,CAAC,CAAC,KAAK,YAAY,QAAQ,CAAC;QAAE,OAAO,OAAO,CAAC;IACjD,MAAM,IAAI,GAAG,cAAc,CAAC,KAAK,CAAC,CAAC;IACnC,OAAO,IAAI,KAAK,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,IAAI,GAAG,IAAI,CAAC,CAAC;AAClE,CAAC;AAED,MAAM,UAAU,aAAa,CAAC,EAC5B,iBAAiB,GAAG,EAAE,EACtB,UAAU,GAAG,CAAC,EACd,gBAAgB,GAAG,2BAA2B,EAC9C,SAAS,GAAG,CAAC,GAAG,EAAE,GAAG,IAAI,EACzB,MAAM,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,EACvB,oBAAoB,GAAG,KAAK,MACJ,EAAE;IAC1B,OAAO;QACL,OAAO,EAAE;YACP,SAAS;YACT,MAAM;YACN,KAAK,EAAE,CAAC,YAAY,EAAE,KAAK,EAAE,EAAE,CAC7B,YAAY,GAAG,UAAU,IAAI,WAAW,CAAC,KAAK,EAAE,iBAAiB,EAAE,gBAAgB,CAAC;YACtF,UAAU,EAAE,YAAY;YACxB,oBAAoB;YACpB,kBAAkB,EAAE,IAAI;SACzB;QACD,2FAA2F;QAC3F,yFAAyF;QACzF,SAAS,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE;KAC5B,CAAC;AACJ,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gusnips/react",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "The headless runtime under a Vite + React SPA: typed fetch client with single-flight refresh, auth store, query defaults, route guards, error boundary, chunk-reload recovery, i18n bootstrap.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,5 +1,5 @@
1
1
  import { afterEach, describe, expect, it, vi } from "vitest";
2
- import { ApiError, isAbortError } from "./api-error.ts";
2
+ import { ApiError, isAbortError, retryAfterSecs } from "./api-error.ts";
3
3
  import { createApiClient, type RefreshResult, type SessionAdapter } from "./api-client.ts";
4
4
 
5
5
  function stubFetch(handler: (url: string, init: RequestInit) => Response | Promise<Response>) {
@@ -65,6 +65,43 @@ describe("createApiClient", () => {
65
65
  });
66
66
  });
67
67
 
68
+ /**
69
+ * The second migration's API states the wait in the HEADER and puts nothing in the body — the
70
+ * standard place, and the one this package did not read. `retryAfterSecs` now answers for
71
+ * either convention, which is what lets one retry rule and one piece of copy serve both.
72
+ */
73
+ it("reads the wait off the Retry-After header", async () => {
74
+ stubFetch(
75
+ () =>
76
+ new Response(JSON.stringify({ error: { code: "RATE_LIMIT_EXCEEDED", message: "slow" } }), {
77
+ status: 429,
78
+ headers: { "Retry-After": "60" },
79
+ }),
80
+ );
81
+ const api = createApiClient({
82
+ baseUrl: "https://api.test",
83
+ session: session(),
84
+ onSessionDead: () => {},
85
+ });
86
+
87
+ const error = await api.get("/x").catch((e: unknown) => e);
88
+ expect(error).toMatchObject({ status: 429, retryAfterSecs: 60 });
89
+ expect(retryAfterSecs(error as ApiError)).toBe(60);
90
+ });
91
+
92
+ it("leaves the wait unset when the answer did not state one", async () => {
93
+ stubFetch(() => new Response("{}", { status: 500 }));
94
+ const api = createApiClient({
95
+ baseUrl: "https://api.test",
96
+ session: session(),
97
+ onSessionDead: () => {},
98
+ });
99
+
100
+ const error = await api.get("/x").catch((e: unknown) => e);
101
+ expect((error as ApiError).retryAfterSecs).toBeUndefined();
102
+ expect(retryAfterSecs(error as ApiError)).toBeNull();
103
+ });
104
+
68
105
  /**
69
106
  * The single-flight rule, and the reason all three donors found it the hard way: the auth
70
107
  * server rotates the refresh token on use, so six parallel 401s sending six refreshes means
@@ -150,15 +187,15 @@ describe("createApiClient", () => {
150
187
 
151
188
  /**
152
189
  * providerkit's invariant 2, on the web side: a caller's Stop and our deadline must stay
153
- * distinguishable. `AbortSignal.timeout` rejects with a **TimeoutError**, not an AbortError,
154
- * so `isAbortError` stays false for it — which is what lets a timeout be retried and reported
190
+ * distinguishable. The deadline aborts with a **TimeoutError**, not an AbortError, so
191
+ * `isAbortError` stays false for it — which is what lets a timeout be retried and reported
155
192
  * while a navigation-cancelled request is silently dropped.
156
193
  */
157
194
  it("distinguishes its own timeout from the caller's abort", async () => {
158
195
  // Note the `aborted` check before the listener. A signal that fired before fetch was ever
159
196
  // called never emits the event, so a listener alone hangs forever — real `fetch` checks the
160
- // flag first, and so must anything standing in for it. That already-aborted race is exactly
161
- // what `AbortSignal.any` in the client exists to cover.
197
+ // flag first, and so must anything standing in for it. That already-aborted race is one of
198
+ // the three things `withDeadline` exists to get right.
162
199
  stubFetch(
163
200
  (_url, init) =>
164
201
  new Promise<Response>((_resolve, reject) => {
@@ -175,13 +212,44 @@ describe("createApiClient", () => {
175
212
  });
176
213
 
177
214
  const timedOut = await api.get("/slow").catch((e: unknown) => e);
178
- expect((timedOut as DOMException).name).toBe("TimeoutError");
215
+ expect((timedOut as Error).name).toBe("TimeoutError");
179
216
  expect(isAbortError(timedOut)).toBe(false);
180
217
 
181
218
  const controller = new AbortController();
182
219
  const cancelled = api.get("/slow", { signal: controller.signal }).catch((e: unknown) => e);
183
220
  controller.abort();
184
221
  expect(isAbortError(await cancelled)).toBe(true);
222
+
223
+ // The already-aborted race: a signal that fired BEFORE the call. A listener alone never
224
+ // hears it, so the request would go out and run to full term with nobody waiting.
225
+ const already = new AbortController();
226
+ already.abort();
227
+ const dead = await api.get("/slow", { signal: already.signal }).catch((e: unknown) => e);
228
+ expect(isAbortError(dead)).toBe(true);
229
+ });
230
+
231
+ /**
232
+ * React Native replaces the global `AbortSignal` with abort-controller@3, which has neither
233
+ * `timeout` nor `any`. The client used both, so `createApiClient` threw
234
+ * `AbortSignal.timeout is not a function` on the phone's first request — in the main entry,
235
+ * the one this package promises works there. Deleting the statics is the cheapest honest
236
+ * stand-in for that runtime.
237
+ */
238
+ it("works where AbortSignal has no static helpers, as on a phone", async () => {
239
+ const { timeout, any } = AbortSignal as unknown as Record<string, unknown>;
240
+ delete (AbortSignal as unknown as Record<string, unknown>).timeout;
241
+ delete (AbortSignal as unknown as Record<string, unknown>).any;
242
+ try {
243
+ stubFetch(() => ok({ id: "1" }));
244
+ const api = createApiClient({
245
+ baseUrl: "https://api.test",
246
+ session: session(),
247
+ onSessionDead: () => {},
248
+ });
249
+ await expect(api.get("/me")).resolves.toEqual({ id: "1" });
250
+ } finally {
251
+ Object.assign(AbortSignal, { timeout, any });
252
+ }
185
253
  });
186
254
 
187
255
  // A 204 has no body, so `res.json()` on one throws `Unexpected end of JSON input` — which
package/src/api-client.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { isApiError, type ApiSuccess } from "@gusnips/http";
2
- import { ApiError } from "./api-error.ts";
2
+ import { ApiError, parseRetryAfter } from "./api-error.ts";
3
3
 
4
4
  /**
5
5
  * The app's one door to the API. Nothing else should call `fetch`.
@@ -144,6 +144,67 @@ export interface ApiClient {
144
144
 
145
145
  const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
146
146
 
147
+ /**
148
+ * One signal that fires on the caller's abort or on the deadline, whichever comes first.
149
+ *
150
+ * `AbortSignal.timeout` plus `AbortSignal.any` is the obvious way to write this, and it is
151
+ * not portable: React Native replaces the global `AbortSignal` with abort-controller@3, which
152
+ * has neither static — so the phone got `AbortSignal.timeout is not a function` on its very
153
+ * first request. This is the same two rules with an `AbortController`, which every runtime
154
+ * this package targets does have, and it is the ONLY path rather than a fallback: one branch
155
+ * cannot drift from the other.
156
+ *
157
+ * Three details that are the whole reason this is not three lines:
158
+ *
159
+ * - **The already-aborted race.** A caller whose signal fired before we attached would never
160
+ * fire the listener, so the request would go out and run to full term. Checked first.
161
+ * - **A timeout must not look like a cancellation.** `isAbortError` exists so a request nobody
162
+ * is waiting for stays silent; a request that ran out of time is a real failure somebody
163
+ * should see. A bare `controller.abort()` raises an `AbortError` and would be swallowed, so
164
+ * the deadline aborts with a plain `Error` — not a `DOMException`, which `isAbortError` is
165
+ * the one thing that checks for.
166
+ * - **The timer is cleared.** Otherwise every completed request leaves a pending timeout for
167
+ * the rest of its budget, which on a phone is a wake-up nobody asked for.
168
+ */
169
+ function withDeadline(
170
+ ms: number,
171
+ caller: AbortSignal | null | undefined,
172
+ ): { signal: AbortSignal; clear: () => void } {
173
+ const controller = new AbortController();
174
+ const timer = setTimeout(() => {
175
+ // Named, not typed: callers identify a deadline by `name === "TimeoutError"`, the same
176
+ // way `AbortSignal.timeout` reports it. A `DOMException` would be the faithful copy and is
177
+ // not constructible everywhere this runs.
178
+ const expired = new Error(`Request timed out after ${String(ms)}ms`);
179
+ expired.name = "TimeoutError";
180
+ controller.abort(expired);
181
+ }, ms);
182
+
183
+ let detach = (): void => {};
184
+ if (caller) {
185
+ if (caller.aborted) {
186
+ clearTimeout(timer);
187
+ controller.abort(caller.reason);
188
+ } else {
189
+ const onAbort = (): void => {
190
+ controller.abort(caller.reason);
191
+ };
192
+ caller.addEventListener("abort", onAbort, { once: true });
193
+ detach = () => {
194
+ caller.removeEventListener("abort", onAbort);
195
+ };
196
+ }
197
+ }
198
+
199
+ return {
200
+ signal: controller.signal,
201
+ clear: () => {
202
+ clearTimeout(timer);
203
+ detach();
204
+ },
205
+ };
206
+ }
207
+
147
208
  export function createApiClient({
148
209
  baseUrl,
149
210
  session,
@@ -215,15 +276,16 @@ export function createApiClient({
215
276
  if (budget === null)
216
277
  return fetch(`${baseUrl}${path}`, { ...init, signal, headers: buildHeaders(token, headers) });
217
278
 
218
- // `AbortSignal.any` rather than a listener: it also catches the already-aborted race, where
219
- // a caller's signal fired before we ever attached. It keeps the caller's abort and our
220
- // timeout distinguishable at the source, which is what lets `isAbortError` stay honest.
221
- const timeout = AbortSignal.timeout(budget);
222
- return fetch(`${baseUrl}${path}`, {
223
- ...init,
224
- signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
225
- headers: buildHeaders(token, headers),
226
- });
279
+ const deadline = withDeadline(budget, signal);
280
+ try {
281
+ return await fetch(`${baseUrl}${path}`, {
282
+ ...init,
283
+ signal: deadline.signal,
284
+ headers: buildHeaders(token, headers),
285
+ });
286
+ } finally {
287
+ deadline.clear();
288
+ }
227
289
  }
228
290
 
229
291
  async function request(path: string, options: RequestOptions = {}): Promise<Response> {
@@ -249,6 +311,7 @@ export function createApiClient({
249
311
  const body: unknown = await res.json().catch(() => null);
250
312
  const error = new ApiError(res.status, isApiError(body) ? body.error : null, {
251
313
  requestId: res.headers.get(requestIdHeader) ?? undefined,
314
+ retryAfterSecs: parseRetryAfter(res.headers.get("retry-after")),
252
315
  });
253
316
  // A listener that throws must not become the error the caller sees: the API failure is
254
317
  // the real news, and swallowing it for a bug in a side effect would send everyone
@@ -0,0 +1,74 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { ApiError, parseRetryAfter, retryAfterSecs } from "./api-error.ts";
3
+
4
+ /**
5
+ * Two APIs, two places for the same fact: one states the wait in the `Retry-After` header (the
6
+ * standard one), the other in `details.retryAfterSecs`. A caller asking "how long" should not
7
+ * have to know which server answered.
8
+ */
9
+ describe("retryAfterSecs", () => {
10
+ it("reads the header the client captured", () => {
11
+ expect(retryAfterSecs(new ApiError(429, null, { retryAfterSecs: 60 }))).toBe(60);
12
+ // Zero is a real answer — "now" — and must not read as "did not say".
13
+ expect(retryAfterSecs(new ApiError(429, null, { retryAfterSecs: 0 }))).toBe(0);
14
+ });
15
+
16
+ it("prefers the header to the body", () => {
17
+ const both = new ApiError(
18
+ 429,
19
+ { code: "X", message: "", details: { retryAfterSecs: 30 } },
20
+ {
21
+ retryAfterSecs: 60,
22
+ },
23
+ );
24
+ expect(retryAfterSecs(both)).toBe(60);
25
+ });
26
+
27
+ it("reads the number out of details, and refuses anything else", () => {
28
+ expect(
29
+ retryAfterSecs(
30
+ new ApiError(429, { code: "X", message: "", details: { retryAfterSecs: 30 } }),
31
+ ),
32
+ ).toBe(30);
33
+ expect(
34
+ retryAfterSecs(
35
+ new ApiError(429, { code: "X", message: "", details: { retryAfterSecs: "30" } }),
36
+ ),
37
+ ).toBeNull();
38
+ expect(retryAfterSecs(new ApiError(429, { code: "X", message: "", details: null }))).toBeNull();
39
+ expect(retryAfterSecs(new ApiError(429, null))).toBeNull();
40
+ });
41
+ });
42
+
43
+ /**
44
+ * Both spec forms are real — RFC 9110 allows delay-seconds or an HTTP-date, and servers pick
45
+ * differently. The date half is the one that gets skipped, which silently turns a stated wait
46
+ * into no wait at all.
47
+ */
48
+ describe("parseRetryAfter", () => {
49
+ it("reads delay-seconds", () => {
50
+ expect(parseRetryAfter("60")).toBe(60);
51
+ expect(parseRetryAfter(" 60 ")).toBe(60);
52
+ expect(parseRetryAfter("0")).toBe(0);
53
+ });
54
+
55
+ it("reads an HTTP-date", () => {
56
+ const at = new Date(Date.now() + 120_000).toUTCString();
57
+ expect(parseRetryAfter(at)).toBeGreaterThanOrEqual(119);
58
+ expect(parseRetryAfter(at)).toBeLessThanOrEqual(120);
59
+ });
60
+
61
+ // A date already gone means "now", not a negative delay that would run the backoff backwards.
62
+ it("clamps a date in the past to zero", () => {
63
+ expect(parseRetryAfter(new Date(Date.now() - 60_000).toUTCString())).toBe(0);
64
+ });
65
+
66
+ it("answers nothing for a header that is missing or not a wait", () => {
67
+ expect(parseRetryAfter(null)).toBeUndefined();
68
+ expect(parseRetryAfter("")).toBeUndefined();
69
+ expect(parseRetryAfter(" ")).toBeUndefined();
70
+ expect(parseRetryAfter("soon")).toBeUndefined();
71
+ expect(parseRetryAfter("-5")).toBeUndefined();
72
+ expect(parseRetryAfter("9007199254740993")).toBeUndefined();
73
+ });
74
+ });
package/src/api-error.ts CHANGED
@@ -18,6 +18,17 @@ export class ApiError extends Error {
18
18
  readonly params: Record<string, string | number> | undefined;
19
19
  readonly details: unknown;
20
20
  readonly requestId: string | undefined;
21
+ /**
22
+ * Seconds until this refusal clears, when the answer said so.
23
+ *
24
+ * Read from the `Retry-After` HEADER, which is where HTTP puts it and where a rate limiter
25
+ * written by anyone puts it. The second migration is why this exists: that API sends
26
+ * `Retry-After: 60` on every 429 and nothing in its body, so the package's only reader —
27
+ * which looked in `details.retryAfterSecs`, the first donor's convention — found nothing, and
28
+ * both the copy and the retry rule were flying blind on the one refusal that states its own
29
+ * expiry. Two conventions, same fact; {@link retryAfterSecs} reads either.
30
+ */
31
+ readonly retryAfterSecs: number | undefined;
21
32
 
22
33
  /**
23
34
  * The client raised this itself to stop a caller while it was already handling the
@@ -33,7 +44,12 @@ export class ApiError extends Error {
33
44
  constructor(
34
45
  status: number,
35
46
  body: ApiErrorBody["error"] | null,
36
- opts: { requestId?: string; expected?: boolean; message?: string } = {},
47
+ opts: {
48
+ requestId?: string;
49
+ expected?: boolean;
50
+ message?: string;
51
+ retryAfterSecs?: number;
52
+ } = {},
37
53
  ) {
38
54
  super(opts.message ?? body?.message ?? `Request failed (${status})`);
39
55
  this.name = "ApiError";
@@ -43,10 +59,50 @@ export class ApiError extends Error {
43
59
  this.params = body?.params;
44
60
  this.details = body?.details;
45
61
  this.requestId = opts.requestId;
62
+ this.retryAfterSecs = opts.retryAfterSecs;
46
63
  this.expected = opts.expected ?? false;
47
64
  }
48
65
  }
49
66
 
67
+ /**
68
+ * Seconds until a refusal clears, from whichever place the API put them.
69
+ *
70
+ * The header first, because it is the standard one and it is what a limiter emits without being
71
+ * asked; then `details.retryAfterSecs`, which one donor's API sends in the body instead. A
72
+ * caller asking "how long" should not have to know which server it is talking to.
73
+ */
74
+ export function retryAfterSecs(error: ApiError): number | null {
75
+ if (error.retryAfterSecs !== undefined) return error.retryAfterSecs;
76
+ const details = error.details;
77
+ if (typeof details !== "object" || details === null) return null;
78
+ const value = (details as { retryAfterSecs?: unknown }).retryAfterSecs;
79
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
80
+ }
81
+
82
+ /**
83
+ * `Retry-After` in either spec form: delay-seconds, or an HTTP-date.
84
+ *
85
+ * Both forms are real — RFC 9110 allows either and different servers pick differently — and the
86
+ * date one is the half that gets skipped, which turns a stated wait into no wait at all. Clamped
87
+ * at zero: a date already in the past means "now", not a negative delay.
88
+ */
89
+ export function parseRetryAfter(value: string | null | undefined): number | undefined {
90
+ const raw = value?.trim();
91
+ if (!raw) return undefined;
92
+
93
+ // A bare integer is the delay-seconds form, malformed included: `Date.parse` accepts some of
94
+ // them as years, so falling through would read "-5" as a date long past and answer 0 — a
95
+ // wait invented out of a header that never stated one.
96
+ if (/^[+-]?\d+$/.test(raw)) {
97
+ const secs = Number(raw);
98
+ return Number.isSafeInteger(secs) && secs >= 0 ? secs : undefined;
99
+ }
100
+
101
+ const at = Date.parse(raw);
102
+ if (Number.isNaN(at)) return undefined;
103
+ return Math.max(0, Math.round((at - Date.now()) / 1000));
104
+ }
105
+
50
106
  /**
51
107
  * An aborted request — the caller's own `AbortController`, or a navigation that unmounted the
52
108
  * component waiting on it.
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { ApiError } from "./api-error.ts";
3
- import { createErrorDescriber, humanizeWait, retryAfterSecs } from "./describe-error.ts";
3
+ import { createErrorDescriber, humanizeWait } from "./describe-error.ts";
4
4
 
5
5
  /** A translator that echoes the key, so a test can see exactly which one was asked for. */
6
6
  const t = (key: string, params?: Record<string, unknown>) =>
@@ -22,23 +22,6 @@ describe("humanizeWait", () => {
22
22
  });
23
23
  });
24
24
 
25
- describe("retryAfterSecs", () => {
26
- it("reads the number out of details, and refuses anything else", () => {
27
- expect(
28
- retryAfterSecs(
29
- new ApiError(429, { code: "X", message: "", details: { retryAfterSecs: 30 } }),
30
- ),
31
- ).toBe(30);
32
- expect(
33
- retryAfterSecs(
34
- new ApiError(429, { code: "X", message: "", details: { retryAfterSecs: "30" } }),
35
- ),
36
- ).toBeNull();
37
- expect(retryAfterSecs(new ApiError(429, { code: "X", message: "", details: null }))).toBeNull();
38
- expect(retryAfterSecs(new ApiError(429, null))).toBeNull();
39
- });
40
- });
41
-
42
25
  describe("createErrorDescriber", () => {
43
26
  const describeError = createErrorDescriber({
44
27
  t,
@@ -101,6 +84,14 @@ describe("createErrorDescriber", () => {
101
84
  expect(describeError(new ApiError(503, null)).hint).toBe("errors.retrySoon");
102
85
  expect(describeError(new ApiError(403, null)).hint).toBeUndefined();
103
86
  });
87
+
88
+ it("never puts the client's own log line on screen", () => {
89
+ // A gateway answers with HTML, so there is no envelope and `message` is the client's
90
+ // `Request failed (502)` — English, and written for a log.
91
+ const { cause, hint } = describeError(new ApiError(502, null));
92
+ expect(cause).toBe("errors.unexpected");
93
+ expect(hint).toBe("errors.retrySoon");
94
+ });
104
95
  });
105
96
 
106
97
  /**
@@ -1,4 +1,4 @@
1
- import { ApiError } from "./api-error.ts";
1
+ import { ApiError, retryAfterSecs } from "./api-error.ts";
2
2
 
3
3
  /**
4
4
  * Turning anything thrown into copy a person can act on.
@@ -75,14 +75,6 @@ type _TypedCatalogNeedsNoCast = Satisfied<
75
75
  : false
76
76
  >;
77
77
 
78
- /** Seconds until a refusal clears, when the envelope carries them. */
79
- export function retryAfterSecs(error: ApiError): number | null {
80
- const details = error.details;
81
- if (typeof details !== "object" || details === null) return null;
82
- const value = (details as { retryAfterSecs?: unknown }).retryAfterSecs;
83
- return typeof value === "number" && Number.isFinite(value) ? value : null;
84
- }
85
-
86
78
  /**
87
79
  * "in 4 minutes" / "in 2 hours" — a wait nobody has to convert from seconds.
88
80
  *
@@ -210,8 +202,14 @@ export function createErrorDescriber<
210
202
  // and support can act on it. A 5xx additionally gets "try again shortly", because that one
211
203
  // genuinely does clear on its own — a 4xx does not, and saying so would be a lie that costs
212
204
  // the reader another attempt.
205
+ //
206
+ // No `code` means no envelope, and then `message` is not the server's: it is the client's
207
+ // own `Request failed (502)`, written for a log. A gateway answering with HTML while the API
208
+ // restarts is exactly when that happens, so it reached screens — until the second migration,
209
+ // whose three apps each guarded against that one string by hand.
210
+ const serverWords = error.code === undefined ? "" : error.message;
213
211
  return {
214
- cause: ctx.says ?? (error.message || t(`${copyPrefix}unexpected`)),
212
+ cause: ctx.says ?? (serverWords || t(`${copyPrefix}unexpected`)),
215
213
  hint: error.status >= 500 ? t(`${copyPrefix}retrySoon`) : undefined,
216
214
  };
217
215
  };
package/src/index.ts CHANGED
@@ -29,7 +29,7 @@
29
29
 
30
30
  export { cn } from "./cn.ts";
31
31
 
32
- export { ApiError, isAbortError } from "./api-error.ts";
32
+ export { ApiError, isAbortError, retryAfterSecs } from "./api-error.ts";
33
33
  export {
34
34
  createApiClient,
35
35
  type ApiClient,
@@ -40,7 +40,7 @@ export {
40
40
  } from "./api-client.ts";
41
41
  export { createSseParser, readSseStream, type SseFrame } from "./sse.ts";
42
42
 
43
- export { queryDefaults, shouldRetry, type QueryDefaultsOptions } from "./query.ts";
43
+ export { queryDefaults, retryDelayMs, shouldRetry, type QueryDefaultsOptions } from "./query.ts";
44
44
 
45
45
  export {
46
46
  ErrorBoundary,
@@ -50,7 +50,6 @@ export {
50
50
  export {
51
51
  createErrorDescriber,
52
52
  humanizeWait,
53
- retryAfterSecs,
54
53
  type DescribedError,
55
54
  type ErrorArm,
56
55
  type ErrorContext,
package/src/query.test.ts CHANGED
@@ -1,10 +1,14 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { ApiError } from "./api-error.ts";
3
- import { shouldRetry } from "./query.ts";
3
+ import { retryDelayMs, shouldRetry } from "./query.ts";
4
4
 
5
5
  const refusal = (status: number, code?: string) =>
6
6
  new ApiError(status, code ? { code, message: code } : null);
7
7
 
8
+ /** A refusal that named its own expiry, the way a `Retry-After` header does. */
9
+ const waited = (status: number, secs: number) =>
10
+ new ApiError(status, null, { retryAfterSecs: secs });
11
+
8
12
  // Invariant 8, and the reason this module exists: three donors wrote three retry rules and
9
13
  // each was right about something the other two got wrong. Every case below is one of those.
10
14
  describe("shouldRetry", () => {
@@ -51,4 +55,52 @@ describe("shouldRetry", () => {
51
55
  it("lets a durable code override the status", () => {
52
56
  expect(shouldRetry(refusal(503, "QUOTA_EXCEEDED"), ["QUOTA_EXCEEDED"])).toBe(false);
53
57
  });
58
+
59
+ // The second migration's rule. Its limiter answers `Retry-After: 60` on a per-minute window,
60
+ // so retrying at 1s and 2s spends two more requests that cannot possibly succeed.
61
+ it("does not retry a stated wait longer than we will hold", () => {
62
+ expect(shouldRetry(waited(429, 60))).toBe(false);
63
+ expect(shouldRetry(waited(429, 2))).toBe(true);
64
+ // The boundary is inclusive: exactly the ceiling is still worth waiting out.
65
+ expect(shouldRetry(waited(429, 10))).toBe(true);
66
+ expect(shouldRetry(waited(429, 11))).toBe(false);
67
+ });
68
+
69
+ it("takes the ceiling from the caller", () => {
70
+ expect(shouldRetry(waited(429, 60), [], 90)).toBe(true);
71
+ expect(shouldRetry(waited(429, 3), [], 1)).toBe(false);
72
+ });
73
+
74
+ // A long wait is an answer whatever the status says — the same judgement `durableLimitCodes`
75
+ // makes, except here the server named it and the app did not have to.
76
+ it("lets a stated wait override a 5xx", () => {
77
+ expect(shouldRetry(waited(503, 300))).toBe(false);
78
+ });
79
+
80
+ it("still retries when nothing said how long", () => {
81
+ expect(shouldRetry(refusal(429))).toBe(true);
82
+ });
83
+ });
84
+
85
+ describe("retryDelayMs", () => {
86
+ it("backs off exponentially when nothing said how long", () => {
87
+ expect(retryDelayMs(0, refusal(500))).toBe(1000);
88
+ expect(retryDelayMs(1, refusal(500))).toBe(2000);
89
+ // …and caps, so a long-lived query does not schedule a retry for next week.
90
+ expect(retryDelayMs(20, refusal(500))).toBe(30_000);
91
+ });
92
+
93
+ it("waits what the server asked for", () => {
94
+ expect(retryDelayMs(0, waited(429, 5))).toBe(5000);
95
+ });
96
+
97
+ // `Retry-After: 0` is a server saying "immediately", which for a client that was just refused
98
+ // is still too soon — the backoff is the floor.
99
+ it("never goes below the backoff", () => {
100
+ expect(retryDelayMs(1, waited(429, 0))).toBe(2000);
101
+ });
102
+
103
+ it("backs off for a failure that is not an API answer at all", () => {
104
+ expect(retryDelayMs(0, new TypeError("Failed to fetch"))).toBe(1000);
105
+ });
54
106
  });
package/src/query.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { DefaultOptions } from "@tanstack/react-query";
2
- import { ApiError } from "./api-error.ts";
2
+ import { ApiError, retryAfterSecs } from "./api-error.ts";
3
3
 
4
4
  /**
5
5
  * react-query defaults — the merge of three repos that each got part of this right.
@@ -15,12 +15,20 @@ import { ApiError } from "./api-error.ts";
15
15
  * - one set **`refetchOnReconnect`**, which none of the others had, and which is the single
16
16
  * most useful refetch there is: coming back from a tunnel is exactly when the screen is stale.
17
17
  *
18
+ * The second migration added the fourth rule, which no donor had: **when a 429 says how long,
19
+ * believe it.** Its limiter answers `Retry-After: 60` on a per-minute window, and the default
20
+ * backoff retries at 1s and 2s — two more requests that cannot succeed, both charged against the
21
+ * same limiter, and three seconds added before the screen says anything. So a stated wait is
22
+ * waited out when it is short, and is an ANSWER when it is not.
23
+ *
18
24
  * This is invariant 8.
19
25
  */
20
26
 
21
27
  /** 4xx statuses a second attempt can fix. Everything else in the 4xx family is an answer. */
22
28
  const RETRYABLE_CLIENT_STATUS = new Set([408, 429]);
23
29
 
30
+ const DEFAULT_MAX_RETRY_WAIT_SECS = 10;
31
+
24
32
  export interface QueryDefaultsOptions {
25
33
  /**
26
34
  * Error codes that mean "this limit does not clear by waiting" — a spent monthly quota, a
@@ -30,6 +38,15 @@ export interface QueryDefaultsOptions {
30
38
  durableLimitCodes?: readonly string[];
31
39
  /** How many times to retry a transient failure. Two donors used 2, one used 1. */
32
40
  maxRetries?: number;
41
+ /**
42
+ * Longest stated wait still worth retrying. Default 10 seconds.
43
+ *
44
+ * Only applies when the refusal named its own expiry (`Retry-After`, or `retryAfterSecs` in
45
+ * the body). A burst limit that clears in two seconds is worth sitting out; a monthly ceiling
46
+ * that clears in an hour is an answer, and holding a spinner for it helps nobody — show the
47
+ * wait instead, which is what `humanizeWait` is for.
48
+ */
49
+ maxRetryWaitSecs?: number;
33
50
  staleTime?: number;
34
51
  gcTime?: number;
35
52
  /**
@@ -46,19 +63,42 @@ export interface QueryDefaultsOptions {
46
63
  * Exported on its own because an app with its own QueryClient config still wants this rule, and
47
64
  * because it is the part worth testing.
48
65
  */
49
- export function shouldRetry(error: unknown, durableLimitCodes: readonly string[] = []): boolean {
66
+ export function shouldRetry(
67
+ error: unknown,
68
+ durableLimitCodes: readonly string[] = [],
69
+ maxRetryWaitSecs = DEFAULT_MAX_RETRY_WAIT_SECS,
70
+ ): boolean {
50
71
  // No response at all — offline, DNS, a dropped connection. The request never landed, so
51
72
  // nothing about it is an answer, and a second attempt is exactly right.
52
73
  if (!(error instanceof ApiError)) return true;
53
74
 
54
75
  if (error.code !== undefined && durableLimitCodes.includes(error.code)) return false;
76
+
77
+ // It told us when it clears. A wait we are not willing to hold is a refusal, not a hiccup —
78
+ // and it is the same judgement `durableLimitCodes` makes, except the server did the naming.
79
+ const wait = retryAfterSecs(error);
80
+ if (wait !== null && wait > maxRetryWaitSecs) return false;
81
+
55
82
  if (error.status >= 500) return true;
56
83
  return RETRYABLE_CLIENT_STATUS.has(error.status);
57
84
  }
58
85
 
86
+ /**
87
+ * How long before the next attempt: the server's stated wait when there is one, react-query's
88
+ * exponential backoff otherwise, and never less than the backoff — a `Retry-After: 0` is a
89
+ * server saying "immediately", which for a client that just got refused is still too soon.
90
+ */
91
+ export function retryDelayMs(attemptIndex: number, error: unknown): number {
92
+ const backoff = Math.min(1000 * 2 ** attemptIndex, 30_000);
93
+ if (!(error instanceof ApiError)) return backoff;
94
+ const wait = retryAfterSecs(error);
95
+ return wait === null ? backoff : Math.max(backoff, wait * 1000);
96
+ }
97
+
59
98
  export function queryDefaults({
60
99
  durableLimitCodes = [],
61
100
  maxRetries = 2,
101
+ maxRetryWaitSecs = DEFAULT_MAX_RETRY_WAIT_SECS,
62
102
  staleTime = 2 * 60 * 1000,
63
103
  gcTime = 10 * 60 * 1000,
64
104
  refetchOnWindowFocus = false,
@@ -68,7 +108,8 @@ export function queryDefaults({
68
108
  staleTime,
69
109
  gcTime,
70
110
  retry: (failureCount, error) =>
71
- failureCount < maxRetries && shouldRetry(error, durableLimitCodes),
111
+ failureCount < maxRetries && shouldRetry(error, durableLimitCodes, maxRetryWaitSecs),
112
+ retryDelay: retryDelayMs,
72
113
  refetchOnWindowFocus,
73
114
  refetchOnReconnect: true,
74
115
  },