@gusnips/react 0.4.0 → 0.4.1
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.
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js +69 -10
- package/dist/api-client.js.map +1 -1
- package/dist/api-error.d.ts +28 -0
- package/dist/api-error.d.ts.map +1 -1
- package/dist/api-error.js +51 -0
- package/dist/api-error.js.map +1 -1
- package/dist/describe-error.d.ts +0 -2
- package/dist/describe-error.d.ts.map +1 -1
- package/dist/describe-error.js +1 -9
- package/dist/describe-error.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/query.d.ts +17 -2
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +29 -4
- package/dist/query.js.map +1 -1
- package/package.json +1 -1
- package/src/api-client.test.ts +74 -6
- package/src/api-client.ts +73 -10
- package/src/api-error.test.ts +74 -0
- package/src/api-error.ts +57 -1
- package/src/describe-error.test.ts +1 -18
- package/src/describe-error.ts +1 -9
- package/src/index.ts +2 -3
- package/src/query.test.ts +53 -1
- package/src/query.ts +44 -3
package/dist/api-client.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"api-client.d.ts","sourceRoot":"","sources":["../src/api-client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAc,KAAK,UAAU,EAAE,MAAM,eAAe,CAAC;AAC5D,OAAO,EAAE,QAAQ,
|
|
1
|
+
{"version":3,"file":"api-client.d.ts","sourceRoot":"","sources":["../src/api-client.ts"],"names":[],"mappings":"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"}
|
package/dist/api-client.js
CHANGED
|
@@ -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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
package/dist/api-client.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"api-client.js","sourceRoot":"","sources":["../src/api-client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAmB,MAAM,eAAe,CAAC;AAC5D,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AA+
|
|
1
|
+
{"version":3,"file":"api-client.js","sourceRoot":"","sources":["../src/api-client.ts"],"names":[],"mappings":"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"}
|
package/dist/api-error.d.ts
CHANGED
|
@@ -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.
|
package/dist/api-error.d.ts.map
CHANGED
|
@@ -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;
|
|
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.
|
package/dist/api-error.js.map
CHANGED
|
@@ -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;
|
|
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"}
|
package/dist/describe-error.d.ts
CHANGED
|
@@ -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,
|
|
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,CAkD9F"}
|
package/dist/describe-error.js
CHANGED
|
@@ -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
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"describe-error.js","sourceRoot":"","sources":["../src/describe-error.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,
|
|
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,OAAO;YACL,KAAK,EAAE,GAAG,CAAC,IAAI,IAAI,CAAC,KAAK,CAAC,OAAO,IAAI,CAAC,CAAC,GAAG,UAAU,YAAY,CAAC,CAAC;YAClE,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,
|
|
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";
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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;
|
|
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,
|
|
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;
|
|
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
|
-
|
|
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
|
package/dist/query.d.ts.map
CHANGED
|
@@ -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;
|
|
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
|
-
|
|
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;
|
|
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.
|
|
3
|
+
"version": "0.4.1",
|
|
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",
|
package/src/api-client.test.ts
CHANGED
|
@@ -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.
|
|
154
|
-
*
|
|
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
|
|
161
|
-
//
|
|
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
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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: {
|
|
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
|
|
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,
|
package/src/describe-error.ts
CHANGED
|
@@ -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
|
*
|
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(
|
|
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
|
},
|