@gusnips/react 0.9.0 → 0.9.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.
- package/README.md +25 -0
- package/dist/api-error.d.ts +26 -0
- package/dist/api-error.d.ts.map +1 -1
- package/dist/api-error.js +31 -0
- package/dist/api-error.js.map +1 -1
- package/dist/guards.d.ts.map +1 -1
- package/dist/guards.js +7 -3
- package/dist/guards.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/internal-path.d.ts +21 -0
- package/dist/internal-path.d.ts.map +1 -1
- package/dist/internal-path.js +32 -0
- package/dist/internal-path.js.map +1 -1
- package/dist/query.d.ts +4 -0
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +13 -1
- package/dist/query.js.map +1 -1
- package/package.json +1 -1
- package/src/api-error.test.ts +46 -1
- package/src/api-error.ts +31 -0
- package/src/guards.test.ts +15 -0
- package/src/guards.tsx +7 -3
- package/src/index.ts +1 -1
- package/src/internal-path.test.ts +46 -1
- package/src/internal-path.ts +41 -0
- package/src/query.test.ts +40 -0
- package/src/query.ts +17 -1
package/README.md
CHANGED
|
@@ -87,6 +87,13 @@ limit both arrive as 429, but one clears by waiting and the other clears by buyi
|
|
|
87
87
|
second burns another request against the limiter to hear the same thing three times, so you name
|
|
88
88
|
those codes and they are never retried.
|
|
89
89
|
|
|
90
|
+
You only need that list where your API stays quiet. If it answers `Retry-After: 2`, the wait is
|
|
91
|
+
sat out; past `maxRetryWaitSecs` (10 seconds by default) the wait is the answer and the screen
|
|
92
|
+
says so instead of holding a spinner. And if it sends `details.retryAfterSecs: null`, it is
|
|
93
|
+
saying waiting will never fix this — a cap that frees only when somebody deletes something, a
|
|
94
|
+
slot that frees when another job ends. That `null` is read as the claim it is, not as a field
|
|
95
|
+
somebody forgot, so the code never reaches your list.
|
|
96
|
+
|
|
90
97
|
## Survives your deploys
|
|
91
98
|
|
|
92
99
|
An app with `lazy()` routes serves chunks by hashed filename. Deploy while someone has a tab
|
|
@@ -176,6 +183,24 @@ const next = safeInternalPath(new URLSearchParams(location.search).get("next"))
|
|
|
176
183
|
|
|
177
184
|
`createRequireAnonymous` already performs that check and falls back to the home path you gave it.
|
|
178
185
|
|
|
186
|
+
Writing a return target down is the other direction, and it has its own hazard. A Supabase client
|
|
187
|
+
with no `flowType` is on the **implicit** flow — the default — so a callback comes back as
|
|
188
|
+
`#access_token=…&refresh_token=…`. A guard renders exactly when there is no session yet, which is
|
|
189
|
+
that callback's own state while the client parses the fragment: point an OAuth `redirectTo` at a
|
|
190
|
+
guarded route and a concatenated `pathname + search + hash` puts a refresh token into a query
|
|
191
|
+
string, where it rides in `Referer`, lands in access logs and stays in history.
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
import { returnPathFromLocation } from "@gusnips/react";
|
|
195
|
+
|
|
196
|
+
// Takes anything shaped like the current page: `window.location`, or `useLocation()`.
|
|
197
|
+
const returnTo = returnPathFromLocation(window.location);
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
It keeps the page and drops only a fragment carrying a credential, because the page is the part
|
|
201
|
+
worth returning to. `createRequireAuth` uses it; use it anywhere else you record where someone was
|
|
202
|
+
— a dead-session redirect is the common one.
|
|
203
|
+
|
|
179
204
|
## Never dead-end anyone
|
|
180
205
|
|
|
181
206
|
```ts
|
package/dist/api-error.d.ts
CHANGED
|
@@ -51,8 +51,34 @@ export declare class ApiError extends Error {
|
|
|
51
51
|
* The header first, because it is the standard one and it is what a limiter emits without being
|
|
52
52
|
* asked; then `details.retryAfterSecs`, which one donor's API sends in the body instead. A
|
|
53
53
|
* caller asking "how long" should not have to know which server it is talking to.
|
|
54
|
+
*
|
|
55
|
+
* It deliberately does not answer whether waiting helps at all. `null` here means "no countdown
|
|
56
|
+
* to show", which is the right answer both for a refusal that said nothing and for one that said
|
|
57
|
+
* waiting will never fix it — and the adopters' countdown UIs read it for exactly that. Telling
|
|
58
|
+
* those two apart is {@link waitingNeverHelps}.
|
|
54
59
|
*/
|
|
55
60
|
export declare function retryAfterSecs(error: ApiError): number | null;
|
|
61
|
+
/**
|
|
62
|
+
* Did the answer say, in so many words, that waiting will never fix this?
|
|
63
|
+
*
|
|
64
|
+
* The server half of this fleet sends `details.retryAfterSecs: null` on a refusal no amount of
|
|
65
|
+
* time clears — a cap that frees only when somebody deletes something, a concurrency slot that
|
|
66
|
+
* frees when another job ends. An explicit `null` is a claim somebody has to read; an omitted key
|
|
67
|
+
* is invisible. That is the whole reason the two must not collapse into one answer: this is the
|
|
68
|
+
* server naming what `durableLimitCodes` otherwise makes every app name by hand, one code at a
|
|
69
|
+
* time, and keep in step with a backend it does not own.
|
|
70
|
+
*
|
|
71
|
+
* Present-and-`null` is the entire test. An absent key reads as `undefined`, so `=== null`
|
|
72
|
+
* already separates the claim from the silence and an `in` check would only widen it to every
|
|
73
|
+
* stated wait. Body-only by design: `Retry-After` has no spelling for "never".
|
|
74
|
+
*
|
|
75
|
+
* Not exported from the package, and that is the point: it is not "is this refusal durable?". It
|
|
76
|
+
* says nothing about a durable CODE or a wait too long to hold, so an error surface branching on
|
|
77
|
+
* it would answer differently from the retry rule for every limit the app names itself. The one
|
|
78
|
+
* question worth asking out loud is `shouldRetry`, and `describeError` already derives the
|
|
79
|
+
* control it offers from exactly that — invariant 8, one rule and two consumers.
|
|
80
|
+
*/
|
|
81
|
+
export declare function waitingNeverHelps(error: ApiError): boolean;
|
|
56
82
|
/**
|
|
57
83
|
* `Retry-After` in either spec form: delay-seconds, or an HTTP-date.
|
|
58
84
|
*
|
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;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
|
|
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;;;;;;;;;;;GAWG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,QAAQ,GAAG,MAAM,GAAG,IAAI,CAM7D;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,QAAQ,GAAG,OAAO,CAI1D;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
|
@@ -56,6 +56,11 @@ export class ApiError extends Error {
|
|
|
56
56
|
* The header first, because it is the standard one and it is what a limiter emits without being
|
|
57
57
|
* asked; then `details.retryAfterSecs`, which one donor's API sends in the body instead. A
|
|
58
58
|
* caller asking "how long" should not have to know which server it is talking to.
|
|
59
|
+
*
|
|
60
|
+
* It deliberately does not answer whether waiting helps at all. `null` here means "no countdown
|
|
61
|
+
* to show", which is the right answer both for a refusal that said nothing and for one that said
|
|
62
|
+
* waiting will never fix it — and the adopters' countdown UIs read it for exactly that. Telling
|
|
63
|
+
* those two apart is {@link waitingNeverHelps}.
|
|
59
64
|
*/
|
|
60
65
|
export function retryAfterSecs(error) {
|
|
61
66
|
if (error.retryAfterSecs !== undefined)
|
|
@@ -66,6 +71,32 @@ export function retryAfterSecs(error) {
|
|
|
66
71
|
const value = details.retryAfterSecs;
|
|
67
72
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
68
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Did the answer say, in so many words, that waiting will never fix this?
|
|
76
|
+
*
|
|
77
|
+
* The server half of this fleet sends `details.retryAfterSecs: null` on a refusal no amount of
|
|
78
|
+
* time clears — a cap that frees only when somebody deletes something, a concurrency slot that
|
|
79
|
+
* frees when another job ends. An explicit `null` is a claim somebody has to read; an omitted key
|
|
80
|
+
* is invisible. That is the whole reason the two must not collapse into one answer: this is the
|
|
81
|
+
* server naming what `durableLimitCodes` otherwise makes every app name by hand, one code at a
|
|
82
|
+
* time, and keep in step with a backend it does not own.
|
|
83
|
+
*
|
|
84
|
+
* Present-and-`null` is the entire test. An absent key reads as `undefined`, so `=== null`
|
|
85
|
+
* already separates the claim from the silence and an `in` check would only widen it to every
|
|
86
|
+
* stated wait. Body-only by design: `Retry-After` has no spelling for "never".
|
|
87
|
+
*
|
|
88
|
+
* Not exported from the package, and that is the point: it is not "is this refusal durable?". It
|
|
89
|
+
* says nothing about a durable CODE or a wait too long to hold, so an error surface branching on
|
|
90
|
+
* it would answer differently from the retry rule for every limit the app names itself. The one
|
|
91
|
+
* question worth asking out loud is `shouldRetry`, and `describeError` already derives the
|
|
92
|
+
* control it offers from exactly that — invariant 8, one rule and two consumers.
|
|
93
|
+
*/
|
|
94
|
+
export function waitingNeverHelps(error) {
|
|
95
|
+
const details = error.details;
|
|
96
|
+
if (typeof details !== "object" || details === null)
|
|
97
|
+
return false;
|
|
98
|
+
return details.retryAfterSecs === null;
|
|
99
|
+
}
|
|
69
100
|
/**
|
|
70
101
|
* `Retry-After` in either spec form: delay-seconds, or an HTTP-date.
|
|
71
102
|
*
|
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;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
|
|
1
|
+
{"version":3,"file":"api-error.js","sourceRoot":"","sources":["../src/api-error.ts"],"names":[],"mappings":"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"}
|
package/dist/guards.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"guards.d.ts","sourceRoot":"","sources":["../src/guards.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAIvC;;;;;;;;;;;;;GAaG;AAEH,kDAAkD;AAClD,MAAM,WAAW,YAAY;IAC3B,eAAe,EAAE,OAAO,CAAC;IACzB,SAAS,EAAE,OAAO,CAAC;CACpB;AAED;;;;;;;;;;;;;;;GAeG;AACH,MAAM,MAAM,OAAO,CAAC,GAAG,IACrB;IAAE,MAAM,EAAE,SAAS,CAAA;CAAE,GAAG;IAAE,MAAM,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,GAAG;IAAE,MAAM,EAAE,SAAS,CAAC;IAAC,IAAI,EAAE,GAAG,CAAA;CAAE,CAAC;AAajG,MAAM,WAAW,YAAY;IAC3B,iEAAiE;IACjE,OAAO,EAAE,SAAS,CAAC;CACpB;AAED,gEAAgE;AAChE,eAAO,MAAM,iBAAiB,SAAS,CAAC;AAOxC;;;GAGG;AACH,wBAAgB,iBAAiB,CAC/B,UAAU,EAAE,MAAM,YAAY,EAC9B,UAAU,EAAE,MAAM,EAClB,EAAE,OAAO,EAAE,EAAE,YAAY,IAEG,cAAc;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,
|
|
1
|
+
{"version":3,"file":"guards.d.ts","sourceRoot":"","sources":["../src/guards.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAIvC;;;;;;;;;;;;;GAaG;AAEH,kDAAkD;AAClD,MAAM,WAAW,YAAY;IAC3B,eAAe,EAAE,OAAO,CAAC;IACzB,SAAS,EAAE,OAAO,CAAC;CACpB;AAED;;;;;;;;;;;;;;;GAeG;AACH,MAAM,MAAM,OAAO,CAAC,GAAG,IACrB;IAAE,MAAM,EAAE,SAAS,CAAA;CAAE,GAAG;IAAE,MAAM,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,GAAG;IAAE,MAAM,EAAE,SAAS,CAAC;IAAC,IAAI,EAAE,GAAG,CAAA;CAAE,CAAC;AAajG,MAAM,WAAW,YAAY;IAC3B,iEAAiE;IACjE,OAAO,EAAE,SAAS,CAAC;CACpB;AAED,gEAAgE;AAChE,eAAO,MAAM,iBAAiB,SAAS,CAAC;AAOxC;;;GAGG;AACH,wBAAgB,iBAAiB,CAC/B,UAAU,EAAE,MAAM,YAAY,EAC9B,UAAU,EAAE,MAAM,EAClB,EAAE,OAAO,EAAE,EAAE,YAAY,IAEG,cAAc;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,iCAiBlE;AAED,oFAAoF;AACpF,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,MAAM,YAAY,EAC9B,QAAQ,EAAE,MAAM,EAChB,EAAE,OAAO,EAAE,EAAE,YAAY,IAEQ,cAAc;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,iCAUvE;AAED;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,KAAK,EAAE,MAAM,OAAO,CAAC,GAAG,CAAC,EAAE,EAAE,OAAO,EAAE,EAAE,YAAY,IAE1F,OAAO,CAAC,EAAE,EAAE,GAAG,KAAK,OAAO,EAC3B,UAAU,SAAS,GAAG,CAAC,MAAM,SAAS,CAAC,EACvC,SAAS,CAAC,KAAK,EAAE,OAAO,KAAK,SAAS,MAEP,cAAc;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,iCAQvE"}
|
package/dist/guards.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { Navigate, useLocation } from "react-router-dom";
|
|
3
|
-
import { safeInternalPath } from "./internal-path.js";
|
|
3
|
+
import { returnPathFromLocation, safeInternalPath } from "./internal-path.js";
|
|
4
4
|
/** Query parameter carrying the route a sign-in interrupted. */
|
|
5
5
|
export const AUTH_RETURN_PARAM = "next";
|
|
6
6
|
function signInUrl(signInPath, returnTo) {
|
|
@@ -20,8 +20,12 @@ export function createRequireAuth(useSession, signInPath, { loading }) {
|
|
|
20
20
|
if (!isAuthenticated) {
|
|
21
21
|
// Query state survives a reload, an OAuth round trip and an email-link round trip. Router
|
|
22
22
|
// state survives none of them, so it only looked like a remembered destination on the
|
|
23
|
-
// password path. The hash belongs too: tabs and anchored settings are real destinations
|
|
24
|
-
|
|
23
|
+
// password path. The hash belongs too: tabs and anchored settings are real destinations —
|
|
24
|
+
// but not when it is the round trip itself, which is why this is not a concatenation. This
|
|
25
|
+
// guard renders exactly when there is no session yet, and that is the state an implicit-flow
|
|
26
|
+
// callback is in while its client parses the fragment: point an OAuth `redirectTo` at a
|
|
27
|
+
// guarded route and the raw hash is a refresh token, on its way into a query string.
|
|
28
|
+
const returnTo = returnPathFromLocation(location);
|
|
25
29
|
return _jsx(Navigate, { to: signInUrl(signInPath, returnTo), replace: true });
|
|
26
30
|
}
|
|
27
31
|
return _jsx(_Fragment, { children: children });
|
package/dist/guards.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"guards.js","sourceRoot":"","sources":["../src/guards.tsx"],"names":[],"mappings":";AAEA,OAAO,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACzD,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;
|
|
1
|
+
{"version":3,"file":"guards.js","sourceRoot":"","sources":["../src/guards.tsx"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { cn } from "./cn.ts";
|
|
2
|
-
export { safeInternalPath } from "./internal-path.ts";
|
|
2
|
+
export { returnPathFromLocation, safeInternalPath, type PathParts } from "./internal-path.ts";
|
|
3
3
|
export { ApiError, isAbortError, retryAfterSecs } from "./api-error.ts";
|
|
4
4
|
export { createApiClient, type ApiClient, type ApiClientOptions, type RefreshResult, type RequestOptions, type SessionAdapter, } from "./api-client.ts";
|
|
5
5
|
export { createSseParser, readSseStream, type SseFrame } from "./sse.ts";
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AA8BA,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAC7B,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AA8BA,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAC7B,OAAO,EAAE,sBAAsB,EAAE,gBAAgB,EAAE,KAAK,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAE9F,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,SAAS,EACd,KAAK,qBAAqB,EAC1B,KAAK,YAAY,EACjB,KAAK,SAAS,GACf,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,YAAY,EACZ,gBAAgB,EAChB,UAAU,GACX,MAAM,sBAAsB,CAAC;AAK9B,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
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
// Check that claim against `dist/`, never against this file: a type-only re-export looks
|
|
29
29
|
// identical in source.
|
|
30
30
|
export { cn } from "./cn.js";
|
|
31
|
-
export { safeInternalPath } from "./internal-path.js";
|
|
31
|
+
export { returnPathFromLocation, safeInternalPath } from "./internal-path.js";
|
|
32
32
|
export { ApiError, isAbortError, retryAfterSecs } from "./api-error.js";
|
|
33
33
|
export { createApiClient, } from "./api-client.js";
|
|
34
34
|
export { createSseParser, readSseStream } from "./sse.js";
|
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,qEAAqE;AACrE,8DAA8D;AAC9D,uEAAuE;AACvE,gEAAgE;AAChE,4EAA4E;AAC5E,EAAE;AACF,2FAA2F;AAC3F,iGAAiG;AACjG,+FAA+F;AAC/F,gGAAgG;AAChG,4EAA4E;AAC5E,EAAE;AACF,2FAA2F;AAC3F,iGAAiG;AACjG,qFAAqF;AACrF,6FAA6F;AAC7F,iGAAiG;AACjG,yFAAyF;AACzF,uBAAuB;AAEvB,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAC7B,OAAO,EAAE,
|
|
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,qEAAqE;AACrE,8DAA8D;AAC9D,uEAAuE;AACvE,gEAAgE;AAChE,4EAA4E;AAC5E,EAAE;AACF,2FAA2F;AAC3F,iGAAiG;AACjG,+FAA+F;AAC/F,gGAAgG;AAChG,4EAA4E;AAC5E,EAAE;AACF,2FAA2F;AAC3F,iGAAiG;AACjG,qFAAqF;AACrF,6FAA6F;AAC7F,iGAAiG;AACjG,yFAAyF;AACzF,uBAAuB;AAEvB,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAC7B,OAAO,EAAE,sBAAsB,EAAE,gBAAgB,EAAkB,MAAM,oBAAoB,CAAC;AAE9F,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,GAQb,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,YAAY,EACZ,gBAAgB,EAChB,UAAU,GACX,MAAM,sBAAsB,CAAC;AAE9B,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/internal-path.d.ts
CHANGED
|
@@ -7,4 +7,25 @@
|
|
|
7
7
|
* Parsing against a fixed origin applies the browser's own rules rather than trying to copy them.
|
|
8
8
|
*/
|
|
9
9
|
export declare function safeInternalPath(value: unknown): string | null;
|
|
10
|
+
/** Anything shaped like the current page: `window.location` and react-router's `useLocation()`. */
|
|
11
|
+
export interface PathParts {
|
|
12
|
+
pathname: string;
|
|
13
|
+
search: string;
|
|
14
|
+
hash: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Where someone is right now, written down so signing in can bring them back.
|
|
18
|
+
*
|
|
19
|
+
* The fragment is the half that is easy to get wrong, and getting it wrong leaks a credential. A
|
|
20
|
+
* Supabase client with no `flowType` uses the IMPLICIT flow, which returns the session in the
|
|
21
|
+
* fragment — `#access_token=…&refresh_token=…`. A return target is recorded in a query string, and
|
|
22
|
+
* a query string is not a fragment: it rides in `Referer`, it is written to every access log on the
|
|
23
|
+
* way, and it stays in history. So a fragment carrying a credential is dropped and the page is
|
|
24
|
+
* kept, because the page is the part worth returning to and the anchor is the part that is
|
|
25
|
+
* dangerous.
|
|
26
|
+
*
|
|
27
|
+
* Total on purpose — the caller is handing over its OWN address, which is on this origin by
|
|
28
|
+
* construction. Use `safeInternalPath` for the other direction, where someone else wrote the value.
|
|
29
|
+
*/
|
|
30
|
+
export declare function returnPathFromLocation({ pathname, search, hash }: PathParts): string;
|
|
10
31
|
//# sourceMappingURL=internal-path.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"internal-path.d.ts","sourceRoot":"","sources":["../src/internal-path.ts"],"names":[],"mappings":"AAUA;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAU9D"}
|
|
1
|
+
{"version":3,"file":"internal-path.d.ts","sourceRoot":"","sources":["../src/internal-path.ts"],"names":[],"mappings":"AAUA;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAU9D;AAeD,mGAAmG;AACnG,MAAM,WAAW,SAAS;IACxB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,sBAAsB,CAAC,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,EAAE,SAAS,GAAG,MAAM,CAKpF"}
|
package/dist/internal-path.js
CHANGED
|
@@ -28,4 +28,36 @@ export function safeInternalPath(value) {
|
|
|
28
28
|
return null;
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* The keys an OAuth or OIDC implicit response puts in the fragment that are themselves credentials
|
|
33
|
+
* (RFC 6749 §4.2.2, plus the two a provider token is passed through under). The rest of that
|
|
34
|
+
* response — `expires_in`, `token_type`, `state`, `type` — is metadata, worthless on its own.
|
|
35
|
+
*/
|
|
36
|
+
const CREDENTIAL_FRAGMENT_KEYS = [
|
|
37
|
+
"access_token",
|
|
38
|
+
"refresh_token",
|
|
39
|
+
"id_token",
|
|
40
|
+
"provider_token",
|
|
41
|
+
"provider_refresh_token",
|
|
42
|
+
];
|
|
43
|
+
/**
|
|
44
|
+
* Where someone is right now, written down so signing in can bring them back.
|
|
45
|
+
*
|
|
46
|
+
* The fragment is the half that is easy to get wrong, and getting it wrong leaks a credential. A
|
|
47
|
+
* Supabase client with no `flowType` uses the IMPLICIT flow, which returns the session in the
|
|
48
|
+
* fragment — `#access_token=…&refresh_token=…`. A return target is recorded in a query string, and
|
|
49
|
+
* a query string is not a fragment: it rides in `Referer`, it is written to every access log on the
|
|
50
|
+
* way, and it stays in history. So a fragment carrying a credential is dropped and the page is
|
|
51
|
+
* kept, because the page is the part worth returning to and the anchor is the part that is
|
|
52
|
+
* dangerous.
|
|
53
|
+
*
|
|
54
|
+
* Total on purpose — the caller is handing over its OWN address, which is on this origin by
|
|
55
|
+
* construction. Use `safeInternalPath` for the other direction, where someone else wrote the value.
|
|
56
|
+
*/
|
|
57
|
+
export function returnPathFromLocation({ pathname, search, hash }) {
|
|
58
|
+
const fragment = hash.startsWith("#") ? hash.slice(1) : hash;
|
|
59
|
+
const params = new URLSearchParams(fragment);
|
|
60
|
+
const carriesCredential = CREDENTIAL_FRAGMENT_KEYS.some((key) => params.has(key));
|
|
61
|
+
return `${pathname}${search}${carriesCredential ? "" : hash}`;
|
|
62
|
+
}
|
|
31
63
|
//# sourceMappingURL=internal-path.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"internal-path.js","sourceRoot":"","sources":["../src/internal-path.ts"],"names":[],"mappings":"AAAA,MAAM,eAAe,GAAG,0BAA0B,CAAC;AAEnD,SAAS,eAAe,CAAC,KAAa;IACpC,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,MAAM,IAAI,GAAG,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC;QAChC,IAAI,IAAI,IAAI,IAAI,IAAI,IAAI,KAAK,IAAI;YAAE,OAAO,IAAI,CAAC;IACjD,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,gBAAgB,CAAC,KAAc;IAC7C,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,eAAe,CAAC,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IAE/F,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,KAAK,EAAE,eAAe,CAAC,CAAC;QAC5C,IAAI,GAAG,CAAC,MAAM,KAAK,eAAe;YAAE,OAAO,IAAI,CAAC;QAChD,OAAO,GAAG,GAAG,CAAC,QAAQ,GAAG,GAAG,CAAC,MAAM,GAAG,GAAG,CAAC,IAAI,EAAE,CAAC;IACnD,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC"}
|
|
1
|
+
{"version":3,"file":"internal-path.js","sourceRoot":"","sources":["../src/internal-path.ts"],"names":[],"mappings":"AAAA,MAAM,eAAe,GAAG,0BAA0B,CAAC;AAEnD,SAAS,eAAe,CAAC,KAAa;IACpC,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,MAAM,IAAI,GAAG,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC;QAChC,IAAI,IAAI,IAAI,IAAI,IAAI,IAAI,KAAK,IAAI;YAAE,OAAO,IAAI,CAAC;IACjD,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,gBAAgB,CAAC,KAAc;IAC7C,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,eAAe,CAAC,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IAE/F,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,KAAK,EAAE,eAAe,CAAC,CAAC;QAC5C,IAAI,GAAG,CAAC,MAAM,KAAK,eAAe;YAAE,OAAO,IAAI,CAAC;QAChD,OAAO,GAAG,GAAG,CAAC,QAAQ,GAAG,GAAG,CAAC,MAAM,GAAG,GAAG,CAAC,IAAI,EAAE,CAAC;IACnD,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED;;;;GAIG;AACH,MAAM,wBAAwB,GAAG;IAC/B,cAAc;IACd,eAAe;IACf,UAAU;IACV,gBAAgB;IAChB,wBAAwB;CACzB,CAAC;AASF;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,sBAAsB,CAAC,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAa;IAC1E,MAAM,QAAQ,GAAG,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;IAC7D,MAAM,MAAM,GAAG,IAAI,eAAe,CAAC,QAAQ,CAAC,CAAC;IAC7C,MAAM,iBAAiB,GAAG,wBAAwB,CAAC,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC;IAClF,OAAO,GAAG,QAAQ,GAAG,MAAM,GAAG,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;AAChE,CAAC"}
|
package/dist/query.d.ts
CHANGED
|
@@ -4,6 +4,10 @@ export interface QueryDefaultsOptions {
|
|
|
4
4
|
* Error codes that mean "this limit does not clear by waiting" — a spent monthly quota, a
|
|
5
5
|
* balance that needs topping up. They usually arrive as 402 or 429; the status alone cannot
|
|
6
6
|
* tell them apart from a burst limit, which is why the caller names them.
|
|
7
|
+
*
|
|
8
|
+
* Only needed where the server does not say so itself. One that sends
|
|
9
|
+
* `details.retryAfterSecs: null` has already made the claim and `shouldRetry` reads it, which
|
|
10
|
+
* is one entry fewer to keep in step with a backend this app does not own.
|
|
7
11
|
*/
|
|
8
12
|
durableLimitCodes?: readonly string[];
|
|
9
13
|
/** How many times to retry a transient failure. Two donors used 2, one used 1. */
|
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;AAoC5D,MAAM,WAAW,oBAAoB;IACnC;;;;;;;;OAQG;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,CAqBT;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, retryAfterSecs } from "./api-error.js";
|
|
1
|
+
import { ApiError, retryAfterSecs, waitingNeverHelps } 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
|
*
|
|
@@ -19,6 +19,11 @@ import { ApiError, retryAfterSecs } from "./api-error.js";
|
|
|
19
19
|
* same limiter, and three seconds added before the screen says anything. So a stated wait is
|
|
20
20
|
* waited out when it is short, and is an ANSWER when it is not.
|
|
21
21
|
*
|
|
22
|
+
* The fifth rule came from the server half of this fleet rather than from a donor: a refusal can
|
|
23
|
+
* state that waiting will never fix it at all, by sending `details.retryAfterSecs: null`. Read as
|
|
24
|
+
* "did not say how long" — which is what every version before this one did — that 429 is retried
|
|
25
|
+
* against a limit no amount of time moves.
|
|
26
|
+
*
|
|
22
27
|
* This is invariant 8.
|
|
23
28
|
*/
|
|
24
29
|
/** 4xx statuses a second attempt can fix. Everything else in the 4xx family is an answer. */
|
|
@@ -37,6 +42,13 @@ export function shouldRetry(error, durableLimitCodes = [], maxRetryWaitSecs = DE
|
|
|
37
42
|
return true;
|
|
38
43
|
if (error.code !== undefined && durableLimitCodes.includes(error.code))
|
|
39
44
|
return false;
|
|
45
|
+
// The same judgement, made by the server instead of by a list the app maintains: an explicit
|
|
46
|
+
// `details.retryAfterSecs: null` means waiting will never fix this. Checked before the stated
|
|
47
|
+
// wait because the two can contradict each other, and the `null` is written by whoever raised
|
|
48
|
+
// this particular refusal where a number can come from a limiter that attaches one to every
|
|
49
|
+
// 429 it emits.
|
|
50
|
+
if (waitingNeverHelps(error))
|
|
51
|
+
return false;
|
|
40
52
|
// It told us when it clears. A wait we are not willing to hold is a refusal, not a hiccup —
|
|
41
53
|
// and it is the same judgement `durableLimitCodes` makes, except the server did the naming.
|
|
42
54
|
const wait = retryAfterSecs(error);
|
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,cAAc,EAAE,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"query.js","sourceRoot":"","sources":["../src/query.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,QAAQ,EAAE,cAAc,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AAE7E;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,6FAA6F;AAC7F,MAAM,uBAAuB,GAAG,IAAI,GAAG,CAAC,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,CAAC;AAEpD,MAAM,2BAA2B,GAAG,EAAE,CAAC;AAkCvC;;;;;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,6FAA6F;IAC7F,8FAA8F;IAC9F,8FAA8F;IAC9F,4FAA4F;IAC5F,gBAAgB;IAChB,IAAI,iBAAiB,CAAC,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC;IAE3C,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.9.
|
|
3
|
+
"version": "0.9.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, stale-tab recovery, i18n bootstrap.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/api-error.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { ApiError, parseRetryAfter, retryAfterSecs } from "./api-error.ts";
|
|
2
|
+
import { ApiError, parseRetryAfter, retryAfterSecs, waitingNeverHelps } from "./api-error.ts";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Two APIs, two places for the same fact: one states the wait in the `Retry-After` header (the
|
|
@@ -38,6 +38,51 @@ describe("retryAfterSecs", () => {
|
|
|
38
38
|
expect(retryAfterSecs(new ApiError(429, { code: "X", message: "", details: null }))).toBeNull();
|
|
39
39
|
expect(retryAfterSecs(new ApiError(429, null))).toBeNull();
|
|
40
40
|
});
|
|
41
|
+
|
|
42
|
+
// And it keeps answering `null` for a stated never, which is what its callers want: there is
|
|
43
|
+
// no countdown to show. The claim is a separate question, asked separately.
|
|
44
|
+
it("answers null for a stated never, same as for silence", () => {
|
|
45
|
+
const stated = new ApiError(429, {
|
|
46
|
+
code: "X",
|
|
47
|
+
message: "",
|
|
48
|
+
details: { retryAfterSecs: null },
|
|
49
|
+
});
|
|
50
|
+
expect(retryAfterSecs(stated)).toBeNull();
|
|
51
|
+
expect(retryAfterSecs(new ApiError(429, null))).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The claim `retryAfterSecs` cannot carry: an explicit `null` in the body, meaning waiting will
|
|
57
|
+
* never fix this. It has to be told apart from an absent key, which is silence.
|
|
58
|
+
*/
|
|
59
|
+
describe("waitingNeverHelps", () => {
|
|
60
|
+
const withDetails = (details: unknown) => new ApiError(429, { code: "X", message: "", details });
|
|
61
|
+
|
|
62
|
+
it("reads a present null as the claim", () => {
|
|
63
|
+
expect(waitingNeverHelps(withDetails({ retryAfterSecs: null }))).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("reads silence as no claim", () => {
|
|
67
|
+
expect(waitingNeverHelps(withDetails({ scope: "account" }))).toBe(false);
|
|
68
|
+
expect(waitingNeverHelps(withDetails({}))).toBe(false);
|
|
69
|
+
expect(waitingNeverHelps(new ApiError(429, null))).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("reads a stated wait as no claim", () => {
|
|
73
|
+
expect(waitingNeverHelps(withDetails({ retryAfterSecs: 30 }))).toBe(false);
|
|
74
|
+
// The header is not a channel for this — there is no `Retry-After` that says "never".
|
|
75
|
+
expect(waitingNeverHelps(new ApiError(429, null, { retryAfterSecs: 30 }))).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// `details` is whatever the server put there, so the shape is a trust boundary. A JSON `null`
|
|
79
|
+
// is the one that bites: `typeof null` is `"object"`, so a missing guard reads a property off
|
|
80
|
+
// it and throws inside a retry rule.
|
|
81
|
+
it("survives a details that is not an object", () => {
|
|
82
|
+
expect(waitingNeverHelps(withDetails(null))).toBe(false);
|
|
83
|
+
expect(waitingNeverHelps(withDetails("nope"))).toBe(false);
|
|
84
|
+
expect(waitingNeverHelps(withDetails(7))).toBe(false);
|
|
85
|
+
});
|
|
41
86
|
});
|
|
42
87
|
|
|
43
88
|
/**
|
package/src/api-error.ts
CHANGED
|
@@ -70,6 +70,11 @@ export class ApiError extends Error {
|
|
|
70
70
|
* The header first, because it is the standard one and it is what a limiter emits without being
|
|
71
71
|
* asked; then `details.retryAfterSecs`, which one donor's API sends in the body instead. A
|
|
72
72
|
* caller asking "how long" should not have to know which server it is talking to.
|
|
73
|
+
*
|
|
74
|
+
* It deliberately does not answer whether waiting helps at all. `null` here means "no countdown
|
|
75
|
+
* to show", which is the right answer both for a refusal that said nothing and for one that said
|
|
76
|
+
* waiting will never fix it — and the adopters' countdown UIs read it for exactly that. Telling
|
|
77
|
+
* those two apart is {@link waitingNeverHelps}.
|
|
73
78
|
*/
|
|
74
79
|
export function retryAfterSecs(error: ApiError): number | null {
|
|
75
80
|
if (error.retryAfterSecs !== undefined) return error.retryAfterSecs;
|
|
@@ -79,6 +84,32 @@ export function retryAfterSecs(error: ApiError): number | null {
|
|
|
79
84
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
80
85
|
}
|
|
81
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Did the answer say, in so many words, that waiting will never fix this?
|
|
89
|
+
*
|
|
90
|
+
* The server half of this fleet sends `details.retryAfterSecs: null` on a refusal no amount of
|
|
91
|
+
* time clears — a cap that frees only when somebody deletes something, a concurrency slot that
|
|
92
|
+
* frees when another job ends. An explicit `null` is a claim somebody has to read; an omitted key
|
|
93
|
+
* is invisible. That is the whole reason the two must not collapse into one answer: this is the
|
|
94
|
+
* server naming what `durableLimitCodes` otherwise makes every app name by hand, one code at a
|
|
95
|
+
* time, and keep in step with a backend it does not own.
|
|
96
|
+
*
|
|
97
|
+
* Present-and-`null` is the entire test. An absent key reads as `undefined`, so `=== null`
|
|
98
|
+
* already separates the claim from the silence and an `in` check would only widen it to every
|
|
99
|
+
* stated wait. Body-only by design: `Retry-After` has no spelling for "never".
|
|
100
|
+
*
|
|
101
|
+
* Not exported from the package, and that is the point: it is not "is this refusal durable?". It
|
|
102
|
+
* says nothing about a durable CODE or a wait too long to hold, so an error surface branching on
|
|
103
|
+
* it would answer differently from the retry rule for every limit the app names itself. The one
|
|
104
|
+
* question worth asking out loud is `shouldRetry`, and `describeError` already derives the
|
|
105
|
+
* control it offers from exactly that — invariant 8, one rule and two consumers.
|
|
106
|
+
*/
|
|
107
|
+
export function waitingNeverHelps(error: ApiError): boolean {
|
|
108
|
+
const details = error.details;
|
|
109
|
+
if (typeof details !== "object" || details === null) return false;
|
|
110
|
+
return (details as { retryAfterSecs?: unknown }).retryAfterSecs === null;
|
|
111
|
+
}
|
|
112
|
+
|
|
82
113
|
/**
|
|
83
114
|
* `Retry-After` in either spec form: delay-seconds, or an HTTP-date.
|
|
84
115
|
*
|
package/src/guards.test.ts
CHANGED
|
@@ -105,6 +105,21 @@ describe("createRequireAuth", () => {
|
|
|
105
105
|
});
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
+
// Point an OAuth `redirectTo` at a guarded route and this is the first render after the provider
|
|
109
|
+
// returns: no session yet, and the implicit flow's tokens still sitting in the address bar. The
|
|
110
|
+
// page is worth remembering; the fragment would be a refresh token written into a query string.
|
|
111
|
+
it("refuses to carry a callback's tokens into the sign-in URL", () => {
|
|
112
|
+
router.useLocation.mockReturnValueOnce({
|
|
113
|
+
pathname: "/dashboard",
|
|
114
|
+
search: "?tab=usage",
|
|
115
|
+
hash: "#access_token=eyJhbG.p.s&refresh_token=v1_abc&token_type=bearer",
|
|
116
|
+
});
|
|
117
|
+
expect(branch(render(guard(false)))).toEqual({
|
|
118
|
+
to: "/login?next=%2Fdashboard%3Ftab%3Dusage",
|
|
119
|
+
state: undefined,
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
108
123
|
it("lets a signed-in visitor through", () => {
|
|
109
124
|
expect(branch(render(guard(true)))).toBe("CONTENT");
|
|
110
125
|
});
|
package/src/guards.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { UseQueryResult } from "@tanstack/react-query";
|
|
2
2
|
import type { ReactNode } from "react";
|
|
3
3
|
import { Navigate, useLocation } from "react-router-dom";
|
|
4
|
-
import { safeInternalPath } from "./internal-path.ts";
|
|
4
|
+
import { returnPathFromLocation, safeInternalPath } from "./internal-path.ts";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Route guards.
|
|
@@ -83,8 +83,12 @@ export function createRequireAuth(
|
|
|
83
83
|
if (!isAuthenticated) {
|
|
84
84
|
// Query state survives a reload, an OAuth round trip and an email-link round trip. Router
|
|
85
85
|
// state survives none of them, so it only looked like a remembered destination on the
|
|
86
|
-
// password path. The hash belongs too: tabs and anchored settings are real destinations
|
|
87
|
-
|
|
86
|
+
// password path. The hash belongs too: tabs and anchored settings are real destinations —
|
|
87
|
+
// but not when it is the round trip itself, which is why this is not a concatenation. This
|
|
88
|
+
// guard renders exactly when there is no session yet, and that is the state an implicit-flow
|
|
89
|
+
// callback is in while its client parses the fragment: point an OAuth `redirectTo` at a
|
|
90
|
+
// guarded route and the raw hash is a refresh token, on its way into a query string.
|
|
91
|
+
const returnTo = returnPathFromLocation(location);
|
|
88
92
|
return <Navigate to={signInUrl(signInPath, returnTo)} replace />;
|
|
89
93
|
}
|
|
90
94
|
return <>{children}</>;
|
package/src/index.ts
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
// identical in source.
|
|
30
30
|
|
|
31
31
|
export { cn } from "./cn.ts";
|
|
32
|
-
export { safeInternalPath } from "./internal-path.ts";
|
|
32
|
+
export { returnPathFromLocation, safeInternalPath, type PathParts } from "./internal-path.ts";
|
|
33
33
|
|
|
34
34
|
export { ApiError, isAbortError, retryAfterSecs } from "./api-error.ts";
|
|
35
35
|
export {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { safeInternalPath } from "./internal-path.ts";
|
|
2
|
+
import { returnPathFromLocation, safeInternalPath } from "./internal-path.ts";
|
|
3
3
|
|
|
4
4
|
describe("safeInternalPath", () => {
|
|
5
5
|
it.each([
|
|
@@ -26,3 +26,48 @@ describe("safeInternalPath", () => {
|
|
|
26
26
|
expect(safeInternalPath(value)).toBeNull();
|
|
27
27
|
});
|
|
28
28
|
});
|
|
29
|
+
|
|
30
|
+
describe("returnPathFromLocation", () => {
|
|
31
|
+
it("keeps the whole address, fragment included", () => {
|
|
32
|
+
expect(
|
|
33
|
+
returnPathFromLocation({
|
|
34
|
+
pathname: "/w/acme/products/api/changes",
|
|
35
|
+
search: "?feature=f1",
|
|
36
|
+
hash: "#change-8f3",
|
|
37
|
+
}),
|
|
38
|
+
).toBe("/w/acme/products/api/changes?feature=f1#change-8f3");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it.each([
|
|
42
|
+
"access_token",
|
|
43
|
+
"refresh_token",
|
|
44
|
+
"id_token",
|
|
45
|
+
"provider_token",
|
|
46
|
+
"provider_refresh_token",
|
|
47
|
+
])("drops a fragment carrying %s, and keeps the page", (key) => {
|
|
48
|
+
expect(
|
|
49
|
+
returnPathFromLocation({
|
|
50
|
+
pathname: "/app",
|
|
51
|
+
search: "?tab=repos",
|
|
52
|
+
hash: `#${key}=ey.J.h&expires_in=3600&token_type=bearer`,
|
|
53
|
+
}),
|
|
54
|
+
).toBe("/app?tab=repos");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("drops the whole implicit response, not only its credential keys", () => {
|
|
58
|
+
// Verbatim shape of what a Supabase client with no `flowType` puts back in the address bar.
|
|
59
|
+
const hash =
|
|
60
|
+
"#access_token=eyJhbG.payload.sig&expires_at=1790000000&expires_in=3600" +
|
|
61
|
+
"&provider_token=gho_x&refresh_token=v1_abc&token_type=bearer&type=magiclink";
|
|
62
|
+
expect(returnPathFromLocation({ pathname: "/app", search: "", hash })).toBe("/app");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("keeps a fragment that only looks like parameters", () => {
|
|
66
|
+
const at = { pathname: "/docs", search: "", hash: "#tab=repos&expires_in=3600" };
|
|
67
|
+
expect(returnPathFromLocation(at)).toBe("/docs#tab=repos&expires_in=3600");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("handles an address with no fragment at all", () => {
|
|
71
|
+
expect(returnPathFromLocation({ pathname: "/", search: "", hash: "" })).toBe("/");
|
|
72
|
+
});
|
|
73
|
+
});
|
package/src/internal-path.ts
CHANGED
|
@@ -27,3 +27,44 @@ export function safeInternalPath(value: unknown): string | null {
|
|
|
27
27
|
return null;
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The keys an OAuth or OIDC implicit response puts in the fragment that are themselves credentials
|
|
33
|
+
* (RFC 6749 §4.2.2, plus the two a provider token is passed through under). The rest of that
|
|
34
|
+
* response — `expires_in`, `token_type`, `state`, `type` — is metadata, worthless on its own.
|
|
35
|
+
*/
|
|
36
|
+
const CREDENTIAL_FRAGMENT_KEYS = [
|
|
37
|
+
"access_token",
|
|
38
|
+
"refresh_token",
|
|
39
|
+
"id_token",
|
|
40
|
+
"provider_token",
|
|
41
|
+
"provider_refresh_token",
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
/** Anything shaped like the current page: `window.location` and react-router's `useLocation()`. */
|
|
45
|
+
export interface PathParts {
|
|
46
|
+
pathname: string;
|
|
47
|
+
search: string;
|
|
48
|
+
hash: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Where someone is right now, written down so signing in can bring them back.
|
|
53
|
+
*
|
|
54
|
+
* The fragment is the half that is easy to get wrong, and getting it wrong leaks a credential. A
|
|
55
|
+
* Supabase client with no `flowType` uses the IMPLICIT flow, which returns the session in the
|
|
56
|
+
* fragment — `#access_token=…&refresh_token=…`. A return target is recorded in a query string, and
|
|
57
|
+
* a query string is not a fragment: it rides in `Referer`, it is written to every access log on the
|
|
58
|
+
* way, and it stays in history. So a fragment carrying a credential is dropped and the page is
|
|
59
|
+
* kept, because the page is the part worth returning to and the anchor is the part that is
|
|
60
|
+
* dangerous.
|
|
61
|
+
*
|
|
62
|
+
* Total on purpose — the caller is handing over its OWN address, which is on this origin by
|
|
63
|
+
* construction. Use `safeInternalPath` for the other direction, where someone else wrote the value.
|
|
64
|
+
*/
|
|
65
|
+
export function returnPathFromLocation({ pathname, search, hash }: PathParts): string {
|
|
66
|
+
const fragment = hash.startsWith("#") ? hash.slice(1) : hash;
|
|
67
|
+
const params = new URLSearchParams(fragment);
|
|
68
|
+
const carriesCredential = CREDENTIAL_FRAGMENT_KEYS.some((key) => params.has(key));
|
|
69
|
+
return `${pathname}${search}${carriesCredential ? "" : hash}`;
|
|
70
|
+
}
|
package/src/query.test.ts
CHANGED
|
@@ -9,6 +9,14 @@ const refusal = (status: number, code?: string) =>
|
|
|
9
9
|
const waited = (status: number, secs: number) =>
|
|
10
10
|
new ApiError(status, null, { retryAfterSecs: secs });
|
|
11
11
|
|
|
12
|
+
/** A refusal whose body says, in so many words, that waiting will never clear it. */
|
|
13
|
+
const never = (status: number) =>
|
|
14
|
+
new ApiError(status, { code: "LIMIT_REACHED", message: "", details: { retryAfterSecs: null } });
|
|
15
|
+
|
|
16
|
+
/** The same body with no such key: details, but no claim either way. */
|
|
17
|
+
const silent = (status: number) =>
|
|
18
|
+
new ApiError(status, { code: "LIMIT_REACHED", message: "", details: { scope: "account" } });
|
|
19
|
+
|
|
12
20
|
// Invariant 8, and the reason this module exists: three donors wrote three retry rules and
|
|
13
21
|
// each was right about something the other two got wrong. Every case below is one of those.
|
|
14
22
|
describe("shouldRetry", () => {
|
|
@@ -56,6 +64,38 @@ describe("shouldRetry", () => {
|
|
|
56
64
|
expect(shouldRetry(refusal(503, "QUOTA_EXCEEDED"), ["QUOTA_EXCEEDED"])).toBe(false);
|
|
57
65
|
});
|
|
58
66
|
|
|
67
|
+
// The same judgement with the server doing the naming. The server half of this fleet sends
|
|
68
|
+
// `details.retryAfterSecs: null` on a refusal no amount of time clears — a cap that frees only
|
|
69
|
+
// when somebody deletes something, a slot that frees when another job ends. Read as "nothing
|
|
70
|
+
// said how long", which is how every version before this one read it, that 429 gets retried
|
|
71
|
+
// against a limit that cannot move, and the app has to keep a `durableLimitCodes` entry for a
|
|
72
|
+
// claim the answer already carries.
|
|
73
|
+
it("does not retry a refusal whose body says waiting never helps", () => {
|
|
74
|
+
expect(shouldRetry(never(429))).toBe(false);
|
|
75
|
+
// And it outranks the status, exactly as a durable code does.
|
|
76
|
+
expect(shouldRetry(never(503))).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// The distinction the rule above rests on: an absent key is silence, not a claim, and silence
|
|
80
|
+
// has always meant "retry a transient failure". Only a key that is present and `null` is the
|
|
81
|
+
// claim — which is why this reads `=== null` and not `in`.
|
|
82
|
+
it("still retries when the body carries details but no stated wait", () => {
|
|
83
|
+
expect(shouldRetry(silent(500))).toBe(true);
|
|
84
|
+
expect(shouldRetry(silent(429))).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// A header wait and a body `null` contradict each other. The `null` is written by whoever
|
|
88
|
+
// raised this particular refusal; the number can come from a limiter that attaches one to
|
|
89
|
+
// every 429 it emits, so the hand-written claim is the more specific one.
|
|
90
|
+
it("takes an explicit never over a stated wait", () => {
|
|
91
|
+
const both = new ApiError(
|
|
92
|
+
429,
|
|
93
|
+
{ code: "LIMIT_REACHED", message: "", details: { retryAfterSecs: null } },
|
|
94
|
+
{ retryAfterSecs: 2 },
|
|
95
|
+
);
|
|
96
|
+
expect(shouldRetry(both)).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
59
99
|
// The second migration's rule. Its limiter answers `Retry-After: 60` on a per-minute window,
|
|
60
100
|
// so retrying at 1s and 2s spends two more requests that cannot possibly succeed.
|
|
61
101
|
it("does not retry a stated wait longer than we will hold", () => {
|
package/src/query.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { DefaultOptions } from "@tanstack/react-query";
|
|
2
|
-
import { ApiError, retryAfterSecs } from "./api-error.ts";
|
|
2
|
+
import { ApiError, retryAfterSecs, waitingNeverHelps } 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.
|
|
@@ -21,6 +21,11 @@ import { ApiError, retryAfterSecs } from "./api-error.ts";
|
|
|
21
21
|
* same limiter, and three seconds added before the screen says anything. So a stated wait is
|
|
22
22
|
* waited out when it is short, and is an ANSWER when it is not.
|
|
23
23
|
*
|
|
24
|
+
* The fifth rule came from the server half of this fleet rather than from a donor: a refusal can
|
|
25
|
+
* state that waiting will never fix it at all, by sending `details.retryAfterSecs: null`. Read as
|
|
26
|
+
* "did not say how long" — which is what every version before this one did — that 429 is retried
|
|
27
|
+
* against a limit no amount of time moves.
|
|
28
|
+
*
|
|
24
29
|
* This is invariant 8.
|
|
25
30
|
*/
|
|
26
31
|
|
|
@@ -34,6 +39,10 @@ export interface QueryDefaultsOptions {
|
|
|
34
39
|
* Error codes that mean "this limit does not clear by waiting" — a spent monthly quota, a
|
|
35
40
|
* balance that needs topping up. They usually arrive as 402 or 429; the status alone cannot
|
|
36
41
|
* tell them apart from a burst limit, which is why the caller names them.
|
|
42
|
+
*
|
|
43
|
+
* Only needed where the server does not say so itself. One that sends
|
|
44
|
+
* `details.retryAfterSecs: null` has already made the claim and `shouldRetry` reads it, which
|
|
45
|
+
* is one entry fewer to keep in step with a backend this app does not own.
|
|
37
46
|
*/
|
|
38
47
|
durableLimitCodes?: readonly string[];
|
|
39
48
|
/** How many times to retry a transient failure. Two donors used 2, one used 1. */
|
|
@@ -74,6 +83,13 @@ export function shouldRetry(
|
|
|
74
83
|
|
|
75
84
|
if (error.code !== undefined && durableLimitCodes.includes(error.code)) return false;
|
|
76
85
|
|
|
86
|
+
// The same judgement, made by the server instead of by a list the app maintains: an explicit
|
|
87
|
+
// `details.retryAfterSecs: null` means waiting will never fix this. Checked before the stated
|
|
88
|
+
// wait because the two can contradict each other, and the `null` is written by whoever raised
|
|
89
|
+
// this particular refusal where a number can come from a limiter that attaches one to every
|
|
90
|
+
// 429 it emits.
|
|
91
|
+
if (waitingNeverHelps(error)) return false;
|
|
92
|
+
|
|
77
93
|
// It told us when it clears. A wait we are not willing to hold is a refusal, not a hiccup —
|
|
78
94
|
// and it is the same judgement `durableLimitCodes` makes, except the server did the naming.
|
|
79
95
|
const wait = retryAfterSecs(error);
|