@gusnips/react 0.9.21 → 0.9.23

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
@@ -396,7 +396,8 @@ adopter writing `fix={hint}` at every error surface.
396
396
  `recover` is derived from `shouldRetry` — the same rule react-query retries on — so the button a
397
397
  reader sees and the retry that actually happens cannot disagree. Give it the same
398
398
  `durableLimitCodes` list you give `queryDefaults`, and a spent quota offers no button instead of
399
- one that cannot work.
399
+ one that cannot work. The words follow it too: "try again in a moment" appears only on a 5xx that
400
+ react-query would retry by itself, so never beside a durable refusal or a long countdown.
400
401
 
401
402
  `formatWait` says how a stated wait becomes words:
402
403
 
@@ -1,4 +1,4 @@
1
- import type { ApiError as ApiErrorBody } from "@gusnips/http";
1
+ import type { ApiErrorBody } from "@gusnips/http";
2
2
  /**
3
3
  * A refusal, with the whole envelope intact.
4
4
  *
@@ -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;;;;;;;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,YAAY,EAAE,MAAM,eAAe,CAAC;AAElD;;;;;;;;;;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;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAEpD"}
@@ -74,15 +74,28 @@ export function createErrorDescriber(options) {
74
74
  if (error.status === 401)
75
75
  return "signin";
76
76
  // It named its own expiry, so there is a real number to count down beside a disabled button.
77
+ // Before the durable list on purpose: a server that says when a refusal clears is believed
78
+ // (invariant 8). "wait" is a countdown the reader watches, not a request anyone sends, so a
79
+ // durable quota that resets in an hour is honest as one. Whether the query layer should wait
80
+ // that hour by itself is a different question, and `shouldRetry` answers it: no.
77
81
  if (waitSecs !== null)
78
82
  return "wait";
79
83
  return shouldRetry(error, durableLimitCodes, maxRetryWaitSecs) ? "retry" : "none";
80
84
  }
81
85
  /**
82
86
  * Anything `codes` does not name. The server's own sentence is still the most specific thing
83
- * we have, and support can act on it. A 5xx additionally gets "try again shortly", because that
84
- * one genuinely does clear on its own — a 4xx does not, and saying so would be a lie that costs
85
- * the reader another attempt.
87
+ * we have, and support can act on it. A 5xx additionally gets "try again shortly" — but only
88
+ * when `recover` says trying can work. A 503 whose code is on `durableLimitCodes` clears with a
89
+ * deploy, never a wait, and "try again in a moment" above no button is two answers to one
90
+ * question: the drift `recover` exists to prevent, arriving through the words instead. One
91
+ * adopter's checkout said "…write to us. Try again in a moment." with `recover: "none"`.
92
+ *
93
+ * A stated wait gets the line only while it is a moment: short enough that `shouldRetry` waits
94
+ * it out itself (`maxRetryWaitSecs`). Past that, "in a moment" over an hour's countdown is wrong
95
+ * the other way round, so the countdown carries it alone and an arm with `ctx.wait` can say when.
96
+ *
97
+ * A 4xx gets nothing here even where the rule retries it (408, 425, a 429). A 429 usually states
98
+ * its wait, and those words belong to an arm for the same reason.
86
99
  *
87
100
  * No `code` means no envelope, and then `message` is not the server's: it is the client's own
88
101
  * `Request failed (502)`, written for a log. A gateway answering with HTML while the API
@@ -93,9 +106,13 @@ export function createErrorDescriber(options) {
93
106
  * `fallback`.
94
107
  */
95
108
  const unmapped = fallback ??
96
- (({ error, says }) => ({
109
+ (({ error, says, recover }) => ({
97
110
  cause: says ?? ((error.code === undefined ? "" : error.message) || copyOf("unexpected")),
98
- fix: error.status >= 500 ? copyOf("retrySoon") : undefined,
111
+ fix: error.status >= 500 &&
112
+ (recover === "retry" ||
113
+ (recover === "wait" && shouldRetry(error, durableLimitCodes, maxRetryWaitSecs)))
114
+ ? copyOf("retrySoon")
115
+ : undefined,
99
116
  }));
100
117
  return function describeError(error) {
101
118
  if (!(error instanceof ApiError)) {
@@ -1 +1 @@
1
- {"version":3,"file":"describe-error.js","sourceRoot":"","sources":["../src/describe-error.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"describe-error.js","sourceRoot":"","sources":["../src/describe-error.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC1C,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AA2GzC,MAAM,aAAa,GAAG,iCAAiC,CAAC;AA2BxD;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,YAAY,CAC1B,CAAoC,EACpC,IAAY,EACZ,MAAc;IAEd,+FAA+F;IAC/F,+BAA+B;IAC/B,IAAI,IAAI,GAAG,EAAE;QAAE,OAAO,CAAC,CAAC,GAAG,MAAM,aAAa,EAAE,EAAE,KAAK,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,EAAE,CAAC,CAAC;IACzF,IAAI,IAAI,GAAG,EAAE,GAAG,EAAE;QAAE,OAAO,CAAC,CAAC,GAAG,MAAM,aAAa,EAAE,EAAE,KAAK,EAAE,IAAI,CAAC,IAAI,CAAC,IAAI,GAAG,EAAE,CAAC,EAAE,CAAC,CAAC;IACtF,OAAO,CAAC,CAAC,GAAG,MAAM,WAAW,EAAE,EAAE,KAAK,EAAE,IAAI,CAAC,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC,EAAE,CAAC,CAAC;AACpE,CAAC;AAkJD,SAAS,eAAe,CAItB,EACA,CAAC,EACD,UAAU,EACV,gBAAgB,EAChB,gBAAgB,EAChB,cAAc,GAAG,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,GACqB;IACxD,6FAA6F;IAC7F,kFAAkF;IAClF,MAAM,KAAK,GAAG,IAAI,GAAG,EAAU,CAAC;IAChC,KAAK,MAAM,IAAI,IAAI,MAAM,CAAC,IAAI,CAAC,gBAAgB,CAAC,EAAE,CAAC;QACjD,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QAChB,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,aAAa,EAAE,EAAE,CAAC,CAAC,CAAC;IAC7C,CAAC;IAED,6FAA6F;IAC7F,2EAA2E;IAC3E,MAAM,GAAG,GAAG,CAAC,IAAY,EAA+C,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IAE3F,OAAO;QACL,MAAM,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,CAAC,GAAG,UAAU,GAAG,IAAI,EAAE,CAAC;QAC3C,cAAc,CAAC,KAAK;YAClB,MAAM,MAAM,GAAG,CAAC,KAAyB,EAAiB,EAAE;gBAC1D,IAAI,KAAK,KAAK,SAAS,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,gBAAgB,CAAC;oBAAE,OAAO,IAAI,CAAC;gBAC5E,MAAM,IAAI,GAAG,KAAK,CAAC,KAAK,CAAC,gBAAgB,CAAC,MAAM,CAAC,CAAC;gBAClD,OAAO,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,gBAAgB,GAAG,IAAI,EAAE,EAAE,cAAc,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;YAC1F,CAAC,CAAC;YACF,wFAAwF;YACxF,4FAA4F;YAC5F,kDAAkD;YAClD,OAAO,CACL,MAAM,CAAC,KAAK,CAAC,UAAU,CAAC;gBACxB,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;QACJ,CAAC;KACF,CAAC;AACJ,CAAC;AAcD,MAAM,UAAU,oBAAoB,CAKlC,OAA8F;IAE9F,MAAM,EAAE,UAAU,EAAE,KAAK,GAAG,EAAE,EAAE,QAAQ,EAAE,iBAAiB,GAAG,EAAE,EAAE,gBAAgB,EAAE,GAAG,OAAO,CAAC;IAC/F,MAAM,EAAE,MAAM,EAAE,cAAc,EAAE,GAC9B,MAAM,IAAI,OAAO;QACf,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,cAAc,EAAE,GAAG,EAAE,CAAC,IAAI,EAAE;QACtE,CAAC,CAAC,eAAe,CAAC,OAAO,CAAC,CAAC;IAE/B;;;;;OAKG;IACH,SAAS,WAAW,CAAC,KAAe,EAAE,QAAuB;QAC3D,6FAA6F;QAC7F,0FAA0F;QAC1F,kFAAkF;QAClF,IAAI,KAAK,CAAC,QAAQ;YAAE,OAAO,MAAM,CAAC;QAClC,6FAA6F;QAC7F,0FAA0F;QAC1F,IAAI,KAAK,CAAC,MAAM,KAAK,GAAG;YAAE,OAAO,QAAQ,CAAC;QAC1C,6FAA6F;QAC7F,2FAA2F;QAC3F,4FAA4F;QAC5F,6FAA6F;QAC7F,iFAAiF;QACjF,IAAI,QAAQ,KAAK,IAAI;YAAE,OAAO,MAAM,CAAC;QACrC,OAAO,WAAW,CAAC,KAAK,EAAE,iBAAiB,EAAE,gBAAgB,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC;IACpF,CAAC;IAED;;;;;;;;;;;;;;;;;;;;;;OAsBG;IACH,MAAM,QAAQ,GACZ,QAAQ;QACR,CAAC,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,EAAE,CAAC,CAAC;YAC9B,KAAK,EAAE,IAAI,IAAI,CAAC,CAAC,KAAK,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,MAAM,CAAC,YAAY,CAAC,CAAC;YACxF,GAAG,EACD,KAAK,CAAC,MAAM,IAAI,GAAG;gBACnB,CAAC,OAAO,KAAK,OAAO;oBAClB,CAAC,OAAO,KAAK,MAAM,IAAI,WAAW,CAAC,KAAK,EAAE,iBAAiB,EAAE,gBAAgB,CAAC,CAAC,CAAC;gBAChF,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC;gBACrB,CAAC,CAAC,SAAS;SAChB,CAAC,CAAC,CAAC;IAEN,OAAO,SAAS,aAAa,CAAC,KAAc;QAC1C,IAAI,CAAC,CAAC,KAAK,YAAY,QAAQ,CAAC,EAAE,CAAC;YACjC,0FAA0F;YAC1F,4FAA4F;YAC5F,qEAAqE;YACrE,OAAO;gBACL,KAAK,EAAE,MAAM,CAAC,SAAS,CAAC;gBACxB,uFAAuF;gBACvF,kFAAkF;gBAClF,GAAG,EAAE,MAAM,CAAC,aAAa,CAAC;gBAC1B,OAAO,EAAE,OAAO;aACjB,CAAC;QACJ,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,UAAU,CAAC,QAAQ,CAAC;YACrD,QAAQ;YACR,OAAO,EAAE,WAAW,CAAC,KAAK,EAAE,QAAQ,CAAC;SACtC,CAAC;QAEF,MAAM,GAAG,GAAG,CAAC,KAAK,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,IAAI,QAAQ,CAAC;QACnF,MAAM,IAAI,GAAc,GAAG,CAAC,GAAG,CAAC,CAAC;QAEjC,2FAA2F;QAC3F,sFAAsF;QACtF,0DAA0D;QAC1D,MAAM,SAAS,GAAG,IAAI,CAAC,SAAS,IAAI,KAAK,CAAC,SAAS,CAAC;QACpD,OAAO;YACL,GAAG,IAAI;YACP,OAAO,EAAE,IAAI,CAAC,OAAO,IAAI,GAAG,CAAC,OAAO;YACpC,GAAG,CAAC,SAAS,KAAK,SAAS,IAAI,EAAE,SAAS,EAAE,CAAC;SAC9C,CAAC;IACJ,CAAC,CAAC;AACJ,CAAC"}
package/dist/query.js CHANGED
@@ -1,4 +1,4 @@
1
- import { retryDelayMs as delayFor, shouldRetry as retryRule } from "@gusnips/http/retry";
1
+ import { retryDelayMs as delayFor, isRetryable as retryRule } from "@gusnips/http/retry";
2
2
  import { ApiError } from "./api-error.js";
3
3
  /**
4
4
  * Should this failure be retried?
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gusnips/react",
3
- "version": "0.9.21",
3
+ "version": "0.9.23",
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",
@@ -69,7 +69,7 @@
69
69
  ],
70
70
  "scripts": {
71
71
  "build": "rm -rf dist && tsc",
72
- "typecheck": "tsc --noEmit && tsc -p typecheck",
72
+ "typecheck": "tsc --noEmit && tsc -p tsconfig.test.json",
73
73
  "test": "vitest run --passWithNoTests",
74
74
  "test:watch": "vitest",
75
75
  "lint": "eslint src",
@@ -80,7 +80,7 @@
80
80
  "release:major": "bun pm version major && bun publish --access public"
81
81
  },
82
82
  "dependencies": {
83
- "@gusnips/http": "0.1.6",
83
+ "@gusnips/http": "0.2.0",
84
84
  "clsx": "^2.1.1",
85
85
  "tailwind-merge": "^3.6.0"
86
86
  },
@@ -125,6 +125,7 @@
125
125
  "@base-ui/react": "^1.8.0",
126
126
  "@supabase/supabase-js": "^2.112.4",
127
127
  "@tanstack/react-query": "^5.102.8",
128
+ "@types/node": "^24.12.0",
128
129
  "@types/react": "^19.2.14",
129
130
  "@types/react-dom": "^19.2.3",
130
131
  "happy-dom": "^20.14.5",
package/src/api-error.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ApiError as ApiErrorBody } from "@gusnips/http";
1
+ import type { ApiErrorBody } from "@gusnips/http";
2
2
 
3
3
  /**
4
4
  * A refusal, with the whole envelope intact.
@@ -152,8 +152,8 @@ describe("createErrorDescriber", () => {
152
152
  });
153
153
 
154
154
  it("offers a retry on a 5xx and withholds it on a 4xx", () => {
155
- // A 5xx genuinely clears on its own. A 4xx does not, and saying so would cost the reader
156
- // another attempt for nothing.
155
+ // A plain 5xx clears on its own. A 403 does not, and saying so would cost the reader another
156
+ // attempt for nothing.
157
157
  expect(describeError(new ApiError(503, null)).fix).toBe("errors.retrySoon");
158
158
  expect(describeError(new ApiError(403, null)).fix).toBeUndefined();
159
159
  });
@@ -178,7 +178,7 @@ describe("the recovery kind", () => {
178
178
  copyPrefix: "errors.",
179
179
  messageKeyPrefix: "serverErrors.",
180
180
  knownMessageKeys: { quotaDay: "" },
181
- durableLimitCodes: ["QUOTA_EXCEEDED"],
181
+ durableLimitCodes: ["QUOTA_EXCEEDED", "NOT_CONFIGURED"],
182
182
  formatWait: (secs) => humanizeWait(t, secs, "errors."),
183
183
  });
184
184
 
@@ -223,6 +223,37 @@ describe("the recovery kind", () => {
223
223
  expect(describeError(error).recover).toBe("retry");
224
224
  });
225
225
 
226
+ it("never says to try again beside a 5xx the rule will not retry", () => {
227
+ // A 503 only a deploy clears. "Try again in a moment" over no button is the same two answers
228
+ // to one question, reached through the words instead of the control.
229
+ const durable = describeError(new ApiError(503, { code: "NOT_CONFIGURED", message: "off" }));
230
+ expect(durable.recover).toBe("none");
231
+ expect(durable.fix).toBeUndefined();
232
+ // The server can say it too, with an explicit `null` wait, and no list involved.
233
+ const never = describeError(
234
+ new ApiError(503, { code: "X", message: "", details: { retryAfterSecs: null } }),
235
+ );
236
+ expect(never.recover).toBe("none");
237
+ expect(never.fix).toBeUndefined();
238
+
239
+ expect(describeError(new ApiError(503, null))).toMatchObject({
240
+ recover: "retry",
241
+ fix: "errors.retrySoon",
242
+ });
243
+ });
244
+
245
+ it("says 'in a moment' over a countdown only when the wait is one", () => {
246
+ // An hour is not a moment. The countdown says when, and the words stay out of its way.
247
+ const long = describeError(new ApiError(503, null, { retryAfterSecs: 3600 }));
248
+ expect(long.recover).toBe("wait");
249
+ expect(long.fix).toBeUndefined();
250
+ // Short enough that react-query waits it out itself, so "in a moment" is true.
251
+ expect(describeError(new ApiError(503, null, { retryAfterSecs: 5 }))).toMatchObject({
252
+ recover: "wait",
253
+ fix: "errors.retrySoon",
254
+ });
255
+ });
256
+
226
257
  it("carries the request id the error contract reserves a slot for", () => {
227
258
  const error = new ApiError(500, null, { requestId: "req_123" });
228
259
  expect(describeError(error).reference).toBe("req_123");
@@ -341,7 +372,8 @@ describe("fallback", () => {
341
372
  { retryAfterSecs: 120 },
342
373
  );
343
374
  expect(describeError(error).fix).toBe('errors.retryIn(errors.waitMinutes({"count":2}))');
344
- expect(createErrorDescriber(options)(error).fix).toBe("errors.retrySoon");
375
+ // Two minutes is past what the query layer waits out, so the built-in says nothing at all.
376
+ expect(createErrorDescriber(options)(error).fix).toBeUndefined();
345
377
  });
346
378
 
347
379
  it("leaves the derived recovery kind alone unless the arm narrows it", () => {
@@ -385,15 +385,28 @@ export function createErrorDescriber<
385
385
  // the session is fine and it is a different account that would help, so it falls through.
386
386
  if (error.status === 401) return "signin";
387
387
  // It named its own expiry, so there is a real number to count down beside a disabled button.
388
+ // Before the durable list on purpose: a server that says when a refusal clears is believed
389
+ // (invariant 8). "wait" is a countdown the reader watches, not a request anyone sends, so a
390
+ // durable quota that resets in an hour is honest as one. Whether the query layer should wait
391
+ // that hour by itself is a different question, and `shouldRetry` answers it: no.
388
392
  if (waitSecs !== null) return "wait";
389
393
  return shouldRetry(error, durableLimitCodes, maxRetryWaitSecs) ? "retry" : "none";
390
394
  }
391
395
 
392
396
  /**
393
397
  * Anything `codes` does not name. The server's own sentence is still the most specific thing
394
- * we have, and support can act on it. A 5xx additionally gets "try again shortly", because that
395
- * one genuinely does clear on its own — a 4xx does not, and saying so would be a lie that costs
396
- * the reader another attempt.
398
+ * we have, and support can act on it. A 5xx additionally gets "try again shortly" — but only
399
+ * when `recover` says trying can work. A 503 whose code is on `durableLimitCodes` clears with a
400
+ * deploy, never a wait, and "try again in a moment" above no button is two answers to one
401
+ * question: the drift `recover` exists to prevent, arriving through the words instead. One
402
+ * adopter's checkout said "…write to us. Try again in a moment." with `recover: "none"`.
403
+ *
404
+ * A stated wait gets the line only while it is a moment: short enough that `shouldRetry` waits
405
+ * it out itself (`maxRetryWaitSecs`). Past that, "in a moment" over an hour's countdown is wrong
406
+ * the other way round, so the countdown carries it alone and an arm with `ctx.wait` can say when.
407
+ *
408
+ * A 4xx gets nothing here even where the rule retries it (408, 425, a 429). A 429 usually states
409
+ * its wait, and those words belong to an arm for the same reason.
397
410
  *
398
411
  * No `code` means no envelope, and then `message` is not the server's: it is the client's own
399
412
  * `Request failed (502)`, written for a log. A gateway answering with HTML while the API
@@ -405,9 +418,14 @@ export function createErrorDescriber<
405
418
  */
406
419
  const unmapped: ErrorArm =
407
420
  fallback ??
408
- (({ error, says }) => ({
421
+ (({ error, says, recover }) => ({
409
422
  cause: says ?? ((error.code === undefined ? "" : error.message) || copyOf("unexpected")),
410
- fix: error.status >= 500 ? copyOf("retrySoon") : undefined,
423
+ fix:
424
+ error.status >= 500 &&
425
+ (recover === "retry" ||
426
+ (recover === "wait" && shouldRetry(error, durableLimitCodes, maxRetryWaitSecs)))
427
+ ? copyOf("retrySoon")
428
+ : undefined,
411
429
  }));
412
430
 
413
431
  return function describeError(error: unknown): DescribedError {
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { ErrorBoundary } from "./error-boundary.tsx";
3
+
4
+ /**
5
+ * The two statics are pure, which is why the reset rule lives in them: it can be checked with no
6
+ * DOM, no renderer and no thrown error.
7
+ *
8
+ * What is worth pinning is that a caught error belongs to the screen it happened on. Wrong one way,
9
+ * navigating away carries the wreckage to every page after it. Wrong the other way, the fallback
10
+ * clears itself at once and re-renders the component that just threw, which throws again: a
11
+ * flicker instead of a state. Lifted from an adopter's copy of this class, deleted when it moved
12
+ * onto the kit's.
13
+ */
14
+ const props = (resetKey: string | undefined) => ({
15
+ resetKey,
16
+ fallback: () => null,
17
+ children: null,
18
+ });
19
+
20
+ describe("ErrorBoundary", () => {
21
+ it("records the error that was thrown", () => {
22
+ const error = new TypeError("outcome is not a key of TONE");
23
+ expect(ErrorBoundary.getDerivedStateFromError(error)).toEqual({ error });
24
+ });
25
+
26
+ it("keeps the error while the route stays put", () => {
27
+ const state = { error: new Error("boom"), key: "/jobs/abc" };
28
+ expect(ErrorBoundary.getDerivedStateFromProps(props("/jobs/abc"), state)).toBeNull();
29
+ });
30
+
31
+ it("clears the error when the route changes", () => {
32
+ const state = { error: new Error("boom"), key: "/jobs/abc" };
33
+ expect(ErrorBoundary.getDerivedStateFromProps(props("/keys"), state)).toEqual({
34
+ error: null,
35
+ key: "/keys",
36
+ });
37
+ });
38
+
39
+ it("tracks the route while nothing has thrown, so the first error is scoped to it", () => {
40
+ // Without this the key would still hold the route the boundary mounted on, and the first
41
+ // error on any later route would clear itself on the next re-render.
42
+ const state = { error: null, key: "/" };
43
+ expect(ErrorBoundary.getDerivedStateFromProps(props("/usage"), state)).toEqual({
44
+ error: null,
45
+ key: "/usage",
46
+ });
47
+ });
48
+ });
package/src/query.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { retryDelayMs as delayFor, shouldRetry as retryRule } from "@gusnips/http/retry";
1
+ import { retryDelayMs as delayFor, isRetryable as retryRule } from "@gusnips/http/retry";
2
2
  import type { DefaultOptions } from "@tanstack/react-query";
3
3
  import { ApiError } from "./api-error.ts";
4
4
 
@@ -85,7 +85,7 @@ function reachable(paths: string[] = []): typeof fetch {
85
85
  };
86
86
  }
87
87
 
88
- function authClient(stored: SupabaseSessionStorage, fetch: typeof fetch) {
88
+ function authClient(stored: SupabaseSessionStorage, fetch: typeof globalThis.fetch) {
89
89
  return createClient(PROJECT_URL, "anon-key", {
90
90
  // Off so that starting the client does not refresh an expired session by itself: the refresh
91
91
  // under test is the one `signOut()` makes.