@gusnips/react 0.9.0 → 0.9.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/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
@@ -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
  *
@@ -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;;;;;;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"}
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
  *
@@ -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;;;;;;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"}
1
+ {"version":3,"file":"api-error.js","sourceRoot":"","sources":["../src/api-error.ts"],"names":[],"mappings":"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"}
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. */
@@ -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;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"}
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;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"}
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.0",
3
+ "version": "0.9.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, stale-tab recovery, i18n bootstrap.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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/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);