@gusnips/react 0.5.2 → 0.6.0

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
@@ -152,7 +152,32 @@ the prop shape _is_ the rule: `problem`, `cause`, `fix`, and `action`. **`fix` a
152
152
  required.** A required prop is the only version of "always offer a way out" that a caller in a
153
153
  hurry cannot skip.
154
154
 
155
- `createErrorDescriber` turns a thrown `ApiError` into the `cause` and `fix` to put in it.
155
+ `createErrorDescriber` turns a thrown `ApiError` into the `cause` and `fix` to put in it — plus
156
+ which control to offer, and the request id to print under it:
157
+
158
+ ```ts
159
+ const { cause, fix, recover, reference } = describeError(error);
160
+ // recover: "retry" | "signin" | "wait" | "none"
161
+ ```
162
+
163
+ The field is `fix`, the same word as the prop above. It was `hint` until 0.6.0, which left every
164
+ adopter writing `fix={hint}` at every error surface.
165
+
166
+ `recover` is derived from `shouldRetry` — the same rule react-query retries on — so the button a
167
+ reader sees and the retry that actually happens cannot disagree. Give it the same
168
+ `durableLimitCodes` list you give `queryDefaults`, and a spent quota offers no button instead of
169
+ one that cannot work.
170
+
171
+ You also say how a stated wait becomes words:
172
+
173
+ ```ts
174
+ formatWait: (secs) => humanizeWait(t, secs, "errors."); // three ICU plural keys
175
+ formatWait: (secs) => formatIn(secs, locale); // or your own Intl formatter
176
+ ```
177
+
178
+ It is required because the default hid its price: `humanizeWait` reads `waitSeconds`,
179
+ `waitMinutes` and `waitHours` out of your catalog, so an app that already formats relative time
180
+ with `Intl` had to add six entries per language before it would compile.
156
181
 
157
182
  ## Also here
158
183
 
@@ -8,20 +8,55 @@ import { ApiError } from "./api-error.ts";
8
8
  * "Monthly quota spent, upgrade or buy a pack" is right in a JSON body and wrong under a button
9
9
  * that could just say what to do next.
10
10
  *
11
- * `cause` is what happened. `hint` is what to do about it. **A surface that shows only the first
11
+ * `cause` is what happened. `fix` is what to do about it. **A surface that shows only the first
12
12
  * half is the dead end this exists to prevent** — which is why the type has two fields and not
13
- * one, and why `hint` being absent should be a deliberate choice at each call site rather than
13
+ * one, and why `fix` being absent should be a deliberate choice at each call site rather than
14
14
  * the default nobody noticed.
15
15
  *
16
+ * `fix`, and not the `hint` this returned until 0.6.0. `ErrorStateProps` — the contract this same
17
+ * package ships for the component that renders this — has always called it `fix`, so every
18
+ * adopter wrote `fix={hint}` at every error surface: a rename adapter, which is the shape of an
19
+ * API we got wrong. It also clears a collision, because in at least one adopter a form field's
20
+ * `hint` is the requirement text under the input, which is a different thing entirely.
21
+ *
16
22
  * The per-code switch stays in the product: two donors' switches shared their SHAPE and almost
17
23
  * no arms, because the arms are that API's vocabulary. What ships is the scaffolding around it,
18
24
  * which is the part that was written twice and got subtly different both times.
19
25
  */
20
- export interface DescribedError {
26
+ /**
27
+ * Which control to offer beside the words.
28
+ *
29
+ * The fourth adopter had this and the package did not, which left every adopter re-deriving
30
+ * "can a second attempt fix this" from its own switch — beside a `shouldRetry` already deciding
31
+ * exactly that for react-query. Two answers to one question drift, and the drift is visible: a
32
+ * screen offering "try again" for a refusal the query layer refuses to retry, so the button does
33
+ * nothing and the reader presses it twice.
34
+ *
35
+ * So this is DERIVED from `shouldRetry`, not switched on separately. One rule, two consumers.
36
+ */
37
+ export type RecoveryKind =
38
+ /** A second attempt can work. Offer the button. */
39
+ "retry"
40
+ /** Auth said no. Offer a way back to sign-in, never a retry. */
41
+ | "signin"
42
+ /** It stated its own expiry. Offer the button, disabled, with the countdown. */
43
+ | "wait"
44
+ /** Waiting cannot fix it and neither can pressing anything. Offer no control. */
45
+ | "none";
46
+ /** The copy an arm writes. Everything it leaves out, {@link createErrorDescriber} fills in. */
47
+ export interface ErrorCopy {
21
48
  /** What happened, in the reader's words. */
22
49
  cause: string;
23
50
  /** What to do about it. Its absence should be a decision, not an oversight. */
24
- hint?: string;
51
+ fix?: string;
52
+ /** Override the derived kind — a 429 whose code means a spent quota is `"none"`, not `"wait"`. */
53
+ recover?: RecoveryKind;
54
+ /** Override the request id. Rarely wanted; the envelope's is used by default. */
55
+ reference?: string;
56
+ }
57
+ export interface DescribedError extends ErrorCopy {
58
+ /** Always present on the way out, whether an arm set it or the rule derived it. */
59
+ recover: RecoveryKind;
25
60
  }
26
61
  /**
27
62
  * The subset of i18next's `t` this module needs — typed here so `i18next` stays optional.
@@ -36,8 +71,16 @@ export interface DescribedError {
36
71
  export type Translate<Key extends string = string> = (key: Key, params?: Record<string, unknown>) => string;
37
72
  /** The three names {@link humanizeWait} looks up, under whatever prefix you pass it. */
38
73
  type WaitName = "waitSeconds" | "waitMinutes" | "waitHours";
39
- /** Every name {@link createErrorDescriber} looks up itself. Your catalog carries all of them. */
40
- type CopyName = WaitName | "network" | "networkHint" | "unexpected" | "retrySoon";
74
+ /**
75
+ * Every name {@link createErrorDescriber} looks up itself. Your catalog carries all of them.
76
+ *
77
+ * The three wait names are deliberately NOT here, which is the other half of 0.6.0. This module
78
+ * used to humanize a stated wait itself, so `waitSeconds`/`waitMinutes`/`waitHours` — each with
79
+ * an ICU plural — landed in the key union of every adopter's `t` whether or not a wait was ever
80
+ * rendered. An app that formats waits with `Intl.RelativeTimeFormat` needs none of them and was
81
+ * left adding six entries per language to satisfy a compiler. `formatWait` owns that now.
82
+ */
83
+ type CopyName = "network" | "networkHint" | "unexpected" | "retrySoon";
41
84
  /**
42
85
  * "in 4 minutes" / "in 2 hours" — a wait nobody has to convert from seconds.
43
86
  *
@@ -45,6 +88,11 @@ type CopyName = WaitName | "network" | "networkHint" | "unexpected" | "retrySoon
45
88
  * not become the less precise "in 1 minute", and 90 minutes rather than 60 for the same reason
46
89
  * one rung up. Needs `waitSeconds` / `waitMinutes` / `waitHours` under `prefix`, each with a
47
90
  * `count` plural.
91
+ *
92
+ * This is what you pass as `formatWait` when your catalog carries those three:
93
+ * `formatWait: (secs) => humanizeWait(t, secs, "errors.")`. An app that formats a wait with
94
+ * `Intl.RelativeTimeFormat` passes its own one-liner instead and needs no wait keys at all —
95
+ * which also gets it correct plurals in languages with more than two forms, for free.
48
96
  */
49
97
  export declare function humanizeWait<Prefix extends string>(t: Translate<`${Prefix}${WaitName}`>, secs: number, prefix: Prefix): string;
50
98
  export interface ErrorContext {
@@ -54,11 +102,28 @@ export interface ErrorContext {
54
102
  /** Seconds until it clears, already humanized. Null when the envelope did not say. */
55
103
  wait: string | null;
56
104
  waitSecs: number | null;
105
+ /**
106
+ * What the rule decided before this arm ran. Read it to agree, return your own to override.
107
+ *
108
+ * Exposed so an arm can narrow rather than restate: a quota arm that already knows the limit is
109
+ * durable returns `"none"`, and every other arm can leave it alone and stay right by default.
110
+ */
111
+ recover: RecoveryKind;
57
112
  }
58
113
  export interface ErrorDescriberOptions<Prefix extends string, ServerPrefix extends string, ServerName extends string> {
59
114
  t: Translate<`${Prefix}${CopyName}` | `${ServerPrefix}${ServerName}`>;
60
115
  /** Catalog namespace for this module's own copy, e.g. `"errors."`. */
61
116
  copyPrefix: Prefix;
117
+ /**
118
+ * How a stated wait becomes words, for the `wait` an arm reads off its context.
119
+ *
120
+ * Required rather than defaulted, because there is no answer that is right for everyone and a
121
+ * default hid the cost: {@link humanizeWait} reads three ICU plural keys out of your catalog,
122
+ * and an app that already formats relative time with `Intl` would have had to add them to
123
+ * compile. Say which you want — `(secs) => humanizeWait(t, secs, "errors.")`, or your own
124
+ * `(secs) => formatIn(secs, locale)` — and the catalog you need is the one you can see.
125
+ */
126
+ formatWait: (secs: number) => string;
62
127
  /**
63
128
  * The server's own catalog namespace, e.g. `"serverErrors."`. A `messageKey` outside it is
64
129
  * ignored — the server names a sentence, it does not get to name any key in the app.
@@ -91,9 +156,17 @@ export interface ErrorDescriberOptions<Prefix extends string, ServerPrefix exten
91
156
  * code that does not exist is a compile error at the arm rather than a branch that never runs.
92
157
  */
93
158
  codes?: Partial<Record<string, ErrorArm>>;
159
+ /**
160
+ * The codes that mean "this limit does not clear by waiting" — **the same list you hand
161
+ * `queryDefaults`**, because it answers the same question and a second copy is a second
162
+ * chance to disagree with the retry rule. Declare it once in the app and pass it twice.
163
+ */
164
+ durableLimitCodes?: readonly string[];
165
+ /** Longest stated wait still worth a retry rather than a countdown. Matches `queryDefaults`. */
166
+ maxRetryWaitSecs?: number;
94
167
  }
95
- export type ErrorArm = (ctx: ErrorContext) => DescribedError;
168
+ export type ErrorArm = (ctx: ErrorContext) => ErrorCopy;
96
169
  /** Build the describer. */
97
- export declare function createErrorDescriber<Prefix extends string, ServerPrefix extends string, ServerName extends string>({ t, copyPrefix, messageKeyPrefix, knownMessageKeys, codes, }: ErrorDescriberOptions<Prefix, ServerPrefix, ServerName>): (error: unknown) => DescribedError;
170
+ export declare function createErrorDescriber<Prefix extends string, ServerPrefix extends string, ServerName extends string>({ t, copyPrefix, formatWait, messageKeyPrefix, knownMessageKeys, codes, durableLimitCodes, maxRetryWaitSecs, }: ErrorDescriberOptions<Prefix, ServerPrefix, ServerName>): (error: unknown) => DescribedError;
98
171
  export {};
99
172
  //# sourceMappingURL=describe-error.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"describe-error.d.ts","sourceRoot":"","sources":["../src/describe-error.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAkB,MAAM,gBAAgB,CAAC;AAE1D;;;;;;;;;;;;;;;;;GAiBG;AAEH,MAAM,WAAW,cAAc;IAC7B,4CAA4C;IAC5C,KAAK,EAAE,MAAM,CAAC;IACd,+EAA+E;IAC/E,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED;;;;;;;;;GASG;AACH,MAAM,MAAM,SAAS,CAAC,GAAG,SAAS,MAAM,GAAG,MAAM,IAAI,CACnD,GAAG,EAAE,GAAG,EACR,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAC7B,MAAM,CAAC;AAEZ,wFAAwF;AACxF,KAAK,QAAQ,GAAG,aAAa,GAAG,aAAa,GAAG,WAAW,CAAC;AAE5D,iGAAiG;AACjG,KAAK,QAAQ,GAAG,QAAQ,GAAG,SAAS,GAAG,aAAa,GAAG,YAAY,GAAG,WAAW,CAAC;AA8BlF;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,MAAM,SAAS,MAAM,EAChD,CAAC,EAAE,SAAS,CAAC,GAAG,MAAM,GAAG,QAAQ,EAAE,CAAC,EACpC,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,GACb,MAAM,CAIR;AAED,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,QAAQ,CAAC;IAChB,2FAA2F;IAC3F,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,sFAAsF;IACtF,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AAED,MAAM,WAAW,qBAAqB,CACpC,MAAM,SAAS,MAAM,EACrB,YAAY,SAAS,MAAM,EAC3B,UAAU,SAAS,MAAM;IAEzB,CAAC,EAAE,SAAS,CAAC,GAAG,MAAM,GAAG,QAAQ,EAAE,GAAG,GAAG,YAAY,GAAG,UAAU,EAAE,CAAC,CAAC;IACtE,sEAAsE;IACtE,UAAU,EAAE,MAAM,CAAC;IACnB;;;;;;;;;OASG;IACH,gBAAgB,EAAE,YAAY,CAAC;IAC/B;;;;;;;;;OASG;IACH,gBAAgB,EAAE,MAAM,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC;IAC9C;;;;;;;;OAQG;IACH,KAAK,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC,CAAC;CAC3C;AAED,MAAM,MAAM,QAAQ,GAAG,CAAC,GAAG,EAAE,YAAY,KAAK,cAAc,CAAC;AAE7D,2BAA2B;AAC3B,wBAAgB,oBAAoB,CAClC,MAAM,SAAS,MAAM,EACrB,YAAY,SAAS,MAAM,EAC3B,UAAU,SAAS,MAAM,EACzB,EACA,CAAC,EACD,UAAU,EACV,gBAAgB,EAChB,gBAAgB,EAChB,KAAU,GACX,EAAE,qBAAqB,CAAC,MAAM,EAAE,YAAY,EAAE,UAAU,CAAC,GAAG,CAAC,KAAK,EAAE,OAAO,KAAK,cAAc,CAwD9F"}
1
+ {"version":3,"file":"describe-error.d.ts","sourceRoot":"","sources":["../src/describe-error.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAkB,MAAM,gBAAgB,CAAC;AAG1D;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH;;;;;;;;;;GAUG;AACH,MAAM,MAAM,YAAY;AACtB,mDAAmD;AACjD,OAAO;AACT,gEAAgE;GAC9D,QAAQ;AACV,gFAAgF;GAC9E,MAAM;AACR,iFAAiF;GAC/E,MAAM,CAAC;AAEX,+FAA+F;AAC/F,MAAM,WAAW,SAAS;IACxB,4CAA4C;IAC5C,KAAK,EAAE,MAAM,CAAC;IACd,+EAA+E;IAC/E,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,kGAAkG;IAClG,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,iFAAiF;IACjF,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,cAAe,SAAQ,SAAS;IAC/C,mFAAmF;IACnF,OAAO,EAAE,YAAY,CAAC;CACvB;AAED;;;;;;;;;GASG;AACH,MAAM,MAAM,SAAS,CAAC,GAAG,SAAS,MAAM,GAAG,MAAM,IAAI,CACnD,GAAG,EAAE,GAAG,EACR,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAC7B,MAAM,CAAC;AAEZ,wFAAwF;AACxF,KAAK,QAAQ,GAAG,aAAa,GAAG,aAAa,GAAG,WAAW,CAAC;AAE5D;;;;;;;;GAQG;AACH,KAAK,QAAQ,GAAG,SAAS,GAAG,aAAa,GAAG,YAAY,GAAG,WAAW,CAAC;AA2BvE;;;;;;;;;;;;GAYG;AACH,wBAAgB,YAAY,CAAC,MAAM,SAAS,MAAM,EAChD,CAAC,EAAE,SAAS,CAAC,GAAG,MAAM,GAAG,QAAQ,EAAE,CAAC,EACpC,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,GACb,MAAM,CAIR;AAED,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,QAAQ,CAAC;IAChB,2FAA2F;IAC3F,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,sFAAsF;IACtF,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB;;;;;OAKG;IACH,OAAO,EAAE,YAAY,CAAC;CACvB;AAED,MAAM,WAAW,qBAAqB,CACpC,MAAM,SAAS,MAAM,EACrB,YAAY,SAAS,MAAM,EAC3B,UAAU,SAAS,MAAM;IAEzB,CAAC,EAAE,SAAS,CAAC,GAAG,MAAM,GAAG,QAAQ,EAAE,GAAG,GAAG,YAAY,GAAG,UAAU,EAAE,CAAC,CAAC;IACtE,sEAAsE;IACtE,UAAU,EAAE,MAAM,CAAC;IACnB;;;;;;;;OAQG;IACH,UAAU,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;IACrC;;;;;;;;;OASG;IACH,gBAAgB,EAAE,YAAY,CAAC;IAC/B;;;;;;;;;OASG;IACH,gBAAgB,EAAE,MAAM,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC;IAC9C;;;;;;;;OAQG;IACH,KAAK,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC,CAAC;IAC1C;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACtC,gGAAgG;IAChG,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,MAAM,QAAQ,GAAG,CAAC,GAAG,EAAE,YAAY,KAAK,SAAS,CAAC;AAExD,2BAA2B;AAC3B,wBAAgB,oBAAoB,CAClC,MAAM,SAAS,MAAM,EACrB,YAAY,SAAS,MAAM,EAC3B,UAAU,SAAS,MAAM,EACzB,EACA,CAAC,EACD,UAAU,EACV,UAAU,EACV,gBAAgB,EAChB,gBAAgB,EAChB,KAAU,EACV,iBAAsB,EACtB,gBAAgB,GACjB,EAAE,qBAAqB,CAAC,MAAM,EAAE,YAAY,EAAE,UAAU,CAAC,GAAG,CAAC,KAAK,EAAE,OAAO,KAAK,cAAc,CA8F9F"}
@@ -1,4 +1,5 @@
1
1
  import { ApiError, retryAfterSecs } from "./api-error.js";
2
+ import { shouldRetry } from "./query.js";
2
3
  /**
3
4
  * "in 4 minutes" / "in 2 hours" — a wait nobody has to convert from seconds.
4
5
  *
@@ -6,6 +7,11 @@ import { ApiError, retryAfterSecs } from "./api-error.js";
6
7
  * not become the less precise "in 1 minute", and 90 minutes rather than 60 for the same reason
7
8
  * one rung up. Needs `waitSeconds` / `waitMinutes` / `waitHours` under `prefix`, each with a
8
9
  * `count` plural.
10
+ *
11
+ * This is what you pass as `formatWait` when your catalog carries those three:
12
+ * `formatWait: (secs) => humanizeWait(t, secs, "errors.")`. An app that formats a wait with
13
+ * `Intl.RelativeTimeFormat` passes its own one-liner instead and needs no wait keys at all —
14
+ * which also gets it correct plurals in languages with more than two forms, for free.
9
15
  */
10
16
  export function humanizeWait(t, secs, prefix) {
11
17
  if (secs < 90)
@@ -15,7 +21,7 @@ export function humanizeWait(t, secs, prefix) {
15
21
  return t(`${prefix}waitHours`, { count: Math.round(secs / 3600) });
16
22
  }
17
23
  /** Build the describer. */
18
- export function createErrorDescriber({ t, copyPrefix, messageKeyPrefix, knownMessageKeys, codes = {}, }) {
24
+ export function createErrorDescriber({ t, copyPrefix, formatWait, messageKeyPrefix, knownMessageKeys, codes = {}, durableLimitCodes = [], maxRetryWaitSecs, }) {
19
25
  const known = new Set(Object.keys(knownMessageKeys));
20
26
  // Sound, and the reason nothing here needs a cast: the set was built from the keys of
21
27
  // `knownMessageKeys`, so membership really does establish the claim. `Object.keys` and not
@@ -34,22 +40,48 @@ export function createErrorDescriber({ t, copyPrefix, messageKeyPrefix, knownMes
34
40
  return (lookup(error.messageKey) ??
35
41
  (error.code === undefined ? null : lookup(`${messageKeyPrefix}${error.code}`)));
36
42
  }
43
+ /**
44
+ * Which control to offer, decided once and never switched on separately.
45
+ *
46
+ * Everything below the last line defers to `shouldRetry` — the rule invariant 8 already owns —
47
+ * so the button a reader sees and the retry react-query performs cannot disagree.
48
+ */
49
+ function recoveryFor(error, waitSecs) {
50
+ // The client raised this one itself, to stop a caller while a dead session was already being
51
+ // handled. Nothing is broken and the person is already on their way to sign-in, so asking
52
+ // them to try again would be the surface contradicting the thing it is reporting.
53
+ if (error.expected)
54
+ return "none";
55
+ // 401 is "we do not know who you are", which no number of attempts answers. 403 is NOT this:
56
+ // the session is fine and it is a different account that would help, so it falls through.
57
+ if (error.status === 401)
58
+ return "signin";
59
+ // It named its own expiry, so there is a real number to count down beside a disabled button.
60
+ if (waitSecs !== null)
61
+ return "wait";
62
+ return shouldRetry(error, durableLimitCodes, maxRetryWaitSecs) ? "retry" : "none";
63
+ }
37
64
  return function describeError(error) {
38
65
  if (!(error instanceof ApiError)) {
39
66
  // Not a response at all — the request never landed. Almost always the network, and almost
40
- // never worth showing a stack trace for.
41
- return { cause: t(`${copyPrefix}network`), hint: t(`${copyPrefix}networkHint`) };
67
+ // never worth showing a stack trace for. `shouldRetry` answers a non-response the same way,
68
+ // so these two agree here without either being told about the other.
69
+ return {
70
+ cause: t(`${copyPrefix}network`),
71
+ // The catalog KEY keeps its name while the field becomes `fix`: renaming it would cost
72
+ // every adopter a JSON edit in every language, for a string no caller ever types.
73
+ fix: t(`${copyPrefix}networkHint`),
74
+ recover: "retry",
75
+ };
42
76
  }
43
77
  const waitSecs = retryAfterSecs(error);
44
78
  const ctx = {
45
79
  error,
46
80
  says: serverSentence(error),
47
- wait: waitSecs === null ? null : humanizeWait(t, waitSecs, copyPrefix),
81
+ wait: waitSecs === null ? null : formatWait(waitSecs),
48
82
  waitSecs,
83
+ recover: recoveryFor(error, waitSecs),
49
84
  };
50
- const arm = error.code === undefined ? undefined : codes[error.code];
51
- if (arm)
52
- return arm(ctx);
53
85
  // Anything unmapped: the server's own sentence is still the most specific thing we have,
54
86
  // and support can act on it. A 5xx additionally gets "try again shortly", because that one
55
87
  // genuinely does clear on its own — a 4xx does not, and saying so would be a lie that costs
@@ -59,10 +91,22 @@ export function createErrorDescriber({ t, copyPrefix, messageKeyPrefix, knownMes
59
91
  // own `Request failed (502)`, written for a log. A gateway answering with HTML while the API
60
92
  // restarts is exactly when that happens, so it reached screens — until the second migration,
61
93
  // whose three apps each guarded against that one string by hand.
62
- const serverWords = error.code === undefined ? "" : error.message;
94
+ const arm = error.code === undefined ? undefined : codes[error.code];
95
+ const copy = arm
96
+ ? arm(ctx)
97
+ : {
98
+ cause: ctx.says ??
99
+ ((error.code === undefined ? "" : error.message) || t(`${copyPrefix}unexpected`)),
100
+ fix: error.status >= 500 ? t(`${copyPrefix}retrySoon`) : undefined,
101
+ };
102
+ // The arm wins where it spoke, the rule fills the rest. `requestId` is the one thing every
103
+ // error surface asks for and nothing was producing — `ErrorStateProps` has reserved a
104
+ // `reference` slot for it since the contract was written.
105
+ const reference = copy.reference ?? error.requestId;
63
106
  return {
64
- cause: ctx.says ?? (serverWords || t(`${copyPrefix}unexpected`)),
65
- hint: error.status >= 500 ? t(`${copyPrefix}retrySoon`) : undefined,
107
+ ...copy,
108
+ recover: copy.recover ?? ctx.recover,
109
+ ...(reference !== undefined && { reference }),
66
110
  };
67
111
  };
68
112
  }
@@ -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":"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"}
package/dist/index.d.ts CHANGED
@@ -4,7 +4,7 @@ export { createApiClient, type ApiClient, type ApiClientOptions, type RefreshRes
4
4
  export { createSseParser, readSseStream, type SseFrame } from "./sse.ts";
5
5
  export { queryDefaults, retryDelayMs, shouldRetry, type QueryDefaultsOptions } from "./query.ts";
6
6
  export { ErrorBoundary, type ErrorBoundaryFallbackProps, type ErrorBoundaryProps, } from "./error-boundary.tsx";
7
- export { createErrorDescriber, humanizeWait, type DescribedError, type ErrorArm, type ErrorContext, type ErrorDescriberOptions, type Translate, } from "./describe-error.ts";
7
+ export { createErrorDescriber, humanizeWait, type DescribedError, type ErrorArm, type ErrorContext, type ErrorCopy, type ErrorDescriberOptions, type RecoveryKind, type Translate, } from "./describe-error.ts";
8
8
  export { installPreloadErrorHandler, isChunkLoadError, isPreloadHintFailure, isStaleBuild, RELOAD_GUARD_KEY, reloadOnce, } from "./deploy-recovery.ts";
9
9
  export { PRERENDERED_ROUTE_ATTR, SHELL_ROUTE } from "./prerender-contract.ts";
10
10
  export { applyBrandVars, i18nInitOptions, type I18nInitOptions } from "./i18n.ts";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AA6BA,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAE7B,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AACxE,OAAO,EACL,eAAe,EACf,KAAK,SAAS,EACd,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,cAAc,EACnB,KAAK,cAAc,GACpB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAE,KAAK,QAAQ,EAAE,MAAM,UAAU,CAAC;AAEzE,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,WAAW,EAAE,KAAK,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAEjG,OAAO,EACL,aAAa,EACb,KAAK,0BAA0B,EAC/B,KAAK,kBAAkB,GACxB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,EACZ,KAAK,cAAc,EACnB,KAAK,QAAQ,EACb,KAAK,YAAY,EACjB,KAAK,qBAAqB,EAC1B,KAAK,SAAS,GACf,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AA6BA,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAE7B,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AACxE,OAAO,EACL,eAAe,EACf,KAAK,SAAS,EACd,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,cAAc,EACnB,KAAK,cAAc,GACpB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAE,KAAK,QAAQ,EAAE,MAAM,UAAU,CAAC;AAEzE,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,WAAW,EAAE,KAAK,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAEjG,OAAO,EACL,aAAa,EACb,KAAK,0BAA0B,EAC/B,KAAK,kBAAkB,GACxB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,EACZ,KAAK,cAAc,EACnB,KAAK,QAAQ,EACb,KAAK,YAAY,EACjB,KAAK,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.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAClE,EAAE;AACF,2CAA2C;AAC3C,EAAE;AACF,+FAA+F;AAC/F,gGAAgG;AAChG,+FAA+F;AAC/F,iGAAiG;AACjG,0BAA0B;AAC1B,EAAE;AACF,oEAAoE;AACpE,6DAA6D;AAC7D,sEAAsE;AACtE,+DAA+D;AAC/D,EAAE;AACF,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;AAE7B,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AACxE,OAAO,EACL,eAAe,GAMhB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAiB,MAAM,UAAU,CAAC;AAEzE,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,WAAW,EAA6B,MAAM,YAAY,CAAC;AAEjG,OAAO,EACL,aAAa,GAGd,MAAM,qBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,GAMb,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,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"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAClE,EAAE;AACF,2CAA2C;AAC3C,EAAE;AACF,+FAA+F;AAC/F,gGAAgG;AAChG,+FAA+F;AAC/F,iGAAiG;AACjG,0BAA0B;AAC1B,EAAE;AACF,oEAAoE;AACpE,6DAA6D;AAC7D,sEAAsE;AACtE,+DAA+D;AAC/D,EAAE;AACF,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;AAE7B,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AACxE,OAAO,EACL,eAAe,GAMhB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAiB,MAAM,UAAU,CAAC;AAEzE,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,WAAW,EAA6B,MAAM,YAAY,CAAC;AAEjG,OAAO,EACL,aAAa,GAGd,MAAM,qBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gusnips/react",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
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,6 +1,7 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { ApiError } from "./api-error.ts";
3
3
  import { createErrorDescriber, humanizeWait } from "./describe-error.ts";
4
+ import { shouldRetry } from "./query.ts";
4
5
 
5
6
  /** A translator that echoes the key, so a test can see exactly which one was asked for. */
6
7
  const t = (key: string, params?: Record<string, unknown>) =>
@@ -22,25 +23,68 @@ describe("humanizeWait", () => {
22
23
  });
23
24
  });
24
25
 
26
+ /**
27
+ * The seam 0.6.0 added, and why it is required rather than defaulted: this module used to
28
+ * humanize the wait itself, which put `waitSeconds`/`waitMinutes`/`waitHours` into the key union
29
+ * of every adopter's `t` — six ICU entries per language — even for an app that formats relative
30
+ * time with `Intl` and would never render one of them.
31
+ */
32
+ describe("formatWait", () => {
33
+ const describeError = createErrorDescriber({
34
+ t,
35
+ copyPrefix: "errors.",
36
+ // What an adopter on `Intl.RelativeTimeFormat` passes. Its catalog has no wait keys at all,
37
+ // and it gets correct plurals in every language without writing one.
38
+ formatWait: (secs) =>
39
+ new Intl.RelativeTimeFormat("en", { numeric: "auto" }).format(
40
+ Math.round(secs / 60),
41
+ "minute",
42
+ ),
43
+ messageKeyPrefix: "serverErrors.",
44
+ knownMessageKeys: {},
45
+ codes: {
46
+ RATE_LIMIT_EXCEEDED: ({ wait }) => ({
47
+ cause: "errors.rateLimit",
48
+ fix: `wait:${String(wait)}`,
49
+ }),
50
+ },
51
+ });
52
+
53
+ it("hands an arm the wait in the app's own words, not the package's", () => {
54
+ const error = new ApiError(
55
+ 429,
56
+ { code: "RATE_LIMIT_EXCEEDED", message: "slow down" },
57
+ { retryAfterSecs: 120 },
58
+ );
59
+ expect(describeError(error).fix).toBe("wait:in 2 minutes");
60
+ });
61
+
62
+ it("leaves the wait null when the refusal never stated one", () => {
63
+ const error = new ApiError(429, { code: "RATE_LIMIT_EXCEEDED", message: "slow down" });
64
+ expect(describeError(error).fix).toBe("wait:null");
65
+ });
66
+ });
67
+
25
68
  describe("createErrorDescriber", () => {
26
69
  const describeError = createErrorDescriber({
27
70
  t,
28
71
  copyPrefix: "errors.",
72
+ formatWait: (secs) => humanizeWait(t, secs, "errors."),
29
73
  messageKeyPrefix: "serverErrors.",
30
74
  knownMessageKeys: { quotaDay: "", suspended: "" },
31
75
  codes: {
32
76
  QUOTA_EXCEEDED: ({ says, wait }) => ({
33
77
  cause: says ?? "errors.quota",
34
- hint: wait ? `errors.retryIn(${wait})` : undefined,
78
+ fix: wait ? `errors.retryIn(${wait})` : undefined,
35
79
  }),
36
80
  },
37
81
  });
38
82
 
39
83
  it("reads a thrown non-response as the network, with a way forward", () => {
40
- const { cause, hint } = describeError(new TypeError("Failed to fetch"));
84
+ const { cause, fix } = describeError(new TypeError("Failed to fetch"));
41
85
  expect(cause).toBe("errors.network");
42
- // The hint is the point. A cause with no hint is the dead end this module exists to stop.
43
- expect(hint).toBe("errors.networkHint");
86
+ // The fix is the point. A cause with no fix is the dead end this module exists to stop.
87
+ expect(fix).toBe("errors.networkHint");
44
88
  });
45
89
 
46
90
  it("resolves a messageKey this build carries, with its params", () => {
@@ -81,16 +125,98 @@ describe("createErrorDescriber", () => {
81
125
  it("offers a retry on a 5xx and withholds it on a 4xx", () => {
82
126
  // A 5xx genuinely clears on its own. A 4xx does not, and saying so would cost the reader
83
127
  // another attempt for nothing.
84
- expect(describeError(new ApiError(503, null)).hint).toBe("errors.retrySoon");
85
- expect(describeError(new ApiError(403, null)).hint).toBeUndefined();
128
+ expect(describeError(new ApiError(503, null)).fix).toBe("errors.retrySoon");
129
+ expect(describeError(new ApiError(403, null)).fix).toBeUndefined();
86
130
  });
87
131
 
88
132
  it("never puts the client's own log line on screen", () => {
89
133
  // A gateway answers with HTML, so there is no envelope and `message` is the client's
90
134
  // `Request failed (502)` — English, and written for a log.
91
- const { cause, hint } = describeError(new ApiError(502, null));
135
+ const { cause, fix } = describeError(new ApiError(502, null));
92
136
  expect(cause).toBe("errors.unexpected");
93
- expect(hint).toBe("errors.retrySoon");
137
+ expect(fix).toBe("errors.retrySoon");
138
+ });
139
+ });
140
+
141
+ /**
142
+ * The control to offer beside the words. The fourth adopter had this and the package did not, so
143
+ * every adopter re-derived "can a second attempt fix this" from its own switch — beside a
144
+ * `shouldRetry` already answering exactly that for react-query.
145
+ */
146
+ describe("the recovery kind", () => {
147
+ const describeError = createErrorDescriber({
148
+ t,
149
+ copyPrefix: "errors.",
150
+ messageKeyPrefix: "serverErrors.",
151
+ knownMessageKeys: { quotaDay: "" },
152
+ durableLimitCodes: ["QUOTA_EXCEEDED"],
153
+ formatWait: (secs) => humanizeWait(t, secs, "errors."),
154
+ });
155
+
156
+ it("sends a 401 to sign-in and leaves a 403 alone", () => {
157
+ expect(describeError(new ApiError(401, null)).recover).toBe("signin");
158
+ // 403 is not a dead session: the session is fine and a different account is the fix, so
159
+ // signing someone out would be the wrong cause AND the wrong remedy.
160
+ expect(describeError(new ApiError(403, null)).recover).toBe("none");
161
+ });
162
+
163
+ it("offers a retry for a request that never landed", () => {
164
+ expect(describeError(new TypeError("Failed to fetch")).recover).toBe("retry");
165
+ });
166
+
167
+ it("counts down when the refusal stated its own expiry", () => {
168
+ const error = new ApiError(
169
+ 429,
170
+ { code: "RATE_LIMIT_EXCEEDED", message: "slow down" },
171
+ { retryAfterSecs: 30 },
172
+ );
173
+ expect(describeError(error).recover).toBe("wait");
174
+ });
175
+
176
+ it("offers no control for a limit that buying clears rather than waiting", () => {
177
+ // The same 429 as above. Only the CODE separates a burst limit from a spent quota, which is
178
+ // why the caller names them — from the same list it hands `queryDefaults`.
179
+ const spent = new ApiError(429, { code: "QUOTA_EXCEEDED", message: "spent" });
180
+ expect(describeError(spent).recover).toBe("none");
181
+ });
182
+
183
+ it("never asks anyone to retry the 401 the client raised itself", () => {
184
+ // `expected` is the sign-out path: nothing is broken and the person is already on their way
185
+ // to the door. A retry button here is the surface contradicting what it reports.
186
+ expect(describeError(new ApiError(401, null, { expected: true })).recover).toBe("none");
187
+ });
188
+
189
+ it("agrees with the retry rule instead of deciding the same thing twice", () => {
190
+ // The whole reason this derives rather than switches: if the two ever disagree, a reader
191
+ // presses a button the query layer has already refused to honour.
192
+ const error = new ApiError(503, null);
193
+ expect(shouldRetry(error)).toBe(true);
194
+ expect(describeError(error).recover).toBe("retry");
195
+ });
196
+
197
+ it("carries the request id the error contract reserves a slot for", () => {
198
+ const error = new ApiError(500, null, { requestId: "req_123" });
199
+ expect(describeError(error).reference).toBe("req_123");
200
+ });
201
+
202
+ it("lets an arm narrow the derived kind without restating it", () => {
203
+ const describe503 = createErrorDescriber({
204
+ t,
205
+ copyPrefix: "errors.",
206
+ formatWait: (secs) => humanizeWait(t, secs, "errors."),
207
+ messageKeyPrefix: "serverErrors.",
208
+ knownMessageKeys: {},
209
+ codes: {
210
+ MAINTENANCE: (ctx) => {
211
+ // The arm reads what the rule decided, so an arm that agrees can stay silent.
212
+ expect(ctx.recover).toBe("retry");
213
+ return { cause: "errors.maintenance", recover: "none" };
214
+ },
215
+ },
216
+ });
217
+ const out = describe503(new ApiError(503, { code: "MAINTENANCE", message: "back soon" }));
218
+ expect(out.recover).toBe("none");
219
+ expect(out.cause).toBe("errors.maintenance");
94
220
  });
95
221
  });
96
222
 
@@ -104,6 +230,7 @@ describe("createErrorDescriber, when the code names the sentence", () => {
104
230
  const describeError = createErrorDescriber({
105
231
  t,
106
232
  copyPrefix: "errors.",
233
+ formatWait: (secs) => humanizeWait(t, secs, "errors."),
107
234
  messageKeyPrefix: "errors.",
108
235
  knownMessageKeys: { FORECAST_NOT_FOUND: "", network: "" },
109
236
  });
@@ -1,4 +1,5 @@
1
1
  import { ApiError, retryAfterSecs } from "./api-error.ts";
2
+ import { shouldRetry } from "./query.ts";
2
3
 
3
4
  /**
4
5
  * Turning anything thrown into copy a person can act on.
@@ -9,21 +10,58 @@ import { ApiError, retryAfterSecs } from "./api-error.ts";
9
10
  * "Monthly quota spent, upgrade or buy a pack" is right in a JSON body and wrong under a button
10
11
  * that could just say what to do next.
11
12
  *
12
- * `cause` is what happened. `hint` is what to do about it. **A surface that shows only the first
13
+ * `cause` is what happened. `fix` is what to do about it. **A surface that shows only the first
13
14
  * half is the dead end this exists to prevent** — which is why the type has two fields and not
14
- * one, and why `hint` being absent should be a deliberate choice at each call site rather than
15
+ * one, and why `fix` being absent should be a deliberate choice at each call site rather than
15
16
  * the default nobody noticed.
16
17
  *
18
+ * `fix`, and not the `hint` this returned until 0.6.0. `ErrorStateProps` — the contract this same
19
+ * package ships for the component that renders this — has always called it `fix`, so every
20
+ * adopter wrote `fix={hint}` at every error surface: a rename adapter, which is the shape of an
21
+ * API we got wrong. It also clears a collision, because in at least one adopter a form field's
22
+ * `hint` is the requirement text under the input, which is a different thing entirely.
23
+ *
17
24
  * The per-code switch stays in the product: two donors' switches shared their SHAPE and almost
18
25
  * no arms, because the arms are that API's vocabulary. What ships is the scaffolding around it,
19
26
  * which is the part that was written twice and got subtly different both times.
20
27
  */
21
28
 
22
- export interface DescribedError {
29
+ /**
30
+ * Which control to offer beside the words.
31
+ *
32
+ * The fourth adopter had this and the package did not, which left every adopter re-deriving
33
+ * "can a second attempt fix this" from its own switch — beside a `shouldRetry` already deciding
34
+ * exactly that for react-query. Two answers to one question drift, and the drift is visible: a
35
+ * screen offering "try again" for a refusal the query layer refuses to retry, so the button does
36
+ * nothing and the reader presses it twice.
37
+ *
38
+ * So this is DERIVED from `shouldRetry`, not switched on separately. One rule, two consumers.
39
+ */
40
+ export type RecoveryKind =
41
+ /** A second attempt can work. Offer the button. */
42
+ | "retry"
43
+ /** Auth said no. Offer a way back to sign-in, never a retry. */
44
+ | "signin"
45
+ /** It stated its own expiry. Offer the button, disabled, with the countdown. */
46
+ | "wait"
47
+ /** Waiting cannot fix it and neither can pressing anything. Offer no control. */
48
+ | "none";
49
+
50
+ /** The copy an arm writes. Everything it leaves out, {@link createErrorDescriber} fills in. */
51
+ export interface ErrorCopy {
23
52
  /** What happened, in the reader's words. */
24
53
  cause: string;
25
54
  /** What to do about it. Its absence should be a decision, not an oversight. */
26
- hint?: string;
55
+ fix?: string;
56
+ /** Override the derived kind — a 429 whose code means a spent quota is `"none"`, not `"wait"`. */
57
+ recover?: RecoveryKind;
58
+ /** Override the request id. Rarely wanted; the envelope's is used by default. */
59
+ reference?: string;
60
+ }
61
+
62
+ export interface DescribedError extends ErrorCopy {
63
+ /** Always present on the way out, whether an arm set it or the rule derived it. */
64
+ recover: RecoveryKind;
27
65
  }
28
66
 
29
67
  /**
@@ -44,8 +82,16 @@ export type Translate<Key extends string = string> = (
44
82
  /** The three names {@link humanizeWait} looks up, under whatever prefix you pass it. */
45
83
  type WaitName = "waitSeconds" | "waitMinutes" | "waitHours";
46
84
 
47
- /** Every name {@link createErrorDescriber} looks up itself. Your catalog carries all of them. */
48
- type CopyName = WaitName | "network" | "networkHint" | "unexpected" | "retrySoon";
85
+ /**
86
+ * Every name {@link createErrorDescriber} looks up itself. Your catalog carries all of them.
87
+ *
88
+ * The three wait names are deliberately NOT here, which is the other half of 0.6.0. This module
89
+ * used to humanize a stated wait itself, so `waitSeconds`/`waitMinutes`/`waitHours` — each with
90
+ * an ICU plural — landed in the key union of every adopter's `t` whether or not a wait was ever
91
+ * rendered. An app that formats waits with `Intl.RelativeTimeFormat` needs none of them and was
92
+ * left adding six entries per language to satisfy a compiler. `formatWait` owns that now.
93
+ */
94
+ type CopyName = "network" | "networkHint" | "unexpected" | "retrySoon";
49
95
 
50
96
  /**
51
97
  * The paragraph above, pinned so it cannot quietly stop being true.
@@ -62,9 +108,6 @@ type ClosedCatalog = (
62
108
  | "errors.networkHint"
63
109
  | "errors.unexpected"
64
110
  | "errors.retrySoon"
65
- | "errors.waitSeconds"
66
- | "errors.waitMinutes"
67
- | "errors.waitHours"
68
111
  | "serverErrors.quotaDay"
69
112
  | "billing.plan",
70
113
  params?: Record<string, unknown>,
@@ -82,6 +125,11 @@ type _TypedCatalogNeedsNoCast = Satisfied<
82
125
  * not become the less precise "in 1 minute", and 90 minutes rather than 60 for the same reason
83
126
  * one rung up. Needs `waitSeconds` / `waitMinutes` / `waitHours` under `prefix`, each with a
84
127
  * `count` plural.
128
+ *
129
+ * This is what you pass as `formatWait` when your catalog carries those three:
130
+ * `formatWait: (secs) => humanizeWait(t, secs, "errors.")`. An app that formats a wait with
131
+ * `Intl.RelativeTimeFormat` passes its own one-liner instead and needs no wait keys at all —
132
+ * which also gets it correct plurals in languages with more than two forms, for free.
85
133
  */
86
134
  export function humanizeWait<Prefix extends string>(
87
135
  t: Translate<`${Prefix}${WaitName}`>,
@@ -100,6 +148,13 @@ export interface ErrorContext {
100
148
  /** Seconds until it clears, already humanized. Null when the envelope did not say. */
101
149
  wait: string | null;
102
150
  waitSecs: number | null;
151
+ /**
152
+ * What the rule decided before this arm ran. Read it to agree, return your own to override.
153
+ *
154
+ * Exposed so an arm can narrow rather than restate: a quota arm that already knows the limit is
155
+ * durable returns `"none"`, and every other arm can leave it alone and stay right by default.
156
+ */
157
+ recover: RecoveryKind;
103
158
  }
104
159
 
105
160
  export interface ErrorDescriberOptions<
@@ -110,6 +165,16 @@ export interface ErrorDescriberOptions<
110
165
  t: Translate<`${Prefix}${CopyName}` | `${ServerPrefix}${ServerName}`>;
111
166
  /** Catalog namespace for this module's own copy, e.g. `"errors."`. */
112
167
  copyPrefix: Prefix;
168
+ /**
169
+ * How a stated wait becomes words, for the `wait` an arm reads off its context.
170
+ *
171
+ * Required rather than defaulted, because there is no answer that is right for everyone and a
172
+ * default hid the cost: {@link humanizeWait} reads three ICU plural keys out of your catalog,
173
+ * and an app that already formats relative time with `Intl` would have had to add them to
174
+ * compile. Say which you want — `(secs) => humanizeWait(t, secs, "errors.")`, or your own
175
+ * `(secs) => formatIn(secs, locale)` — and the catalog you need is the one you can see.
176
+ */
177
+ formatWait: (secs: number) => string;
113
178
  /**
114
179
  * The server's own catalog namespace, e.g. `"serverErrors."`. A `messageKey` outside it is
115
180
  * ignored — the server names a sentence, it does not get to name any key in the app.
@@ -142,9 +207,17 @@ export interface ErrorDescriberOptions<
142
207
  * code that does not exist is a compile error at the arm rather than a branch that never runs.
143
208
  */
144
209
  codes?: Partial<Record<string, ErrorArm>>;
210
+ /**
211
+ * The codes that mean "this limit does not clear by waiting" — **the same list you hand
212
+ * `queryDefaults`**, because it answers the same question and a second copy is a second
213
+ * chance to disagree with the retry rule. Declare it once in the app and pass it twice.
214
+ */
215
+ durableLimitCodes?: readonly string[];
216
+ /** Longest stated wait still worth a retry rather than a countdown. Matches `queryDefaults`. */
217
+ maxRetryWaitSecs?: number;
145
218
  }
146
219
 
147
- export type ErrorArm = (ctx: ErrorContext) => DescribedError;
220
+ export type ErrorArm = (ctx: ErrorContext) => ErrorCopy;
148
221
 
149
222
  /** Build the describer. */
150
223
  export function createErrorDescriber<
@@ -154,9 +227,12 @@ export function createErrorDescriber<
154
227
  >({
155
228
  t,
156
229
  copyPrefix,
230
+ formatWait,
157
231
  messageKeyPrefix,
158
232
  knownMessageKeys,
159
233
  codes = {},
234
+ durableLimitCodes = [],
235
+ maxRetryWaitSecs,
160
236
  }: ErrorDescriberOptions<Prefix, ServerPrefix, ServerName>): (error: unknown) => DescribedError {
161
237
  const known = new Set<string>(Object.keys(knownMessageKeys));
162
238
 
@@ -180,24 +256,48 @@ export function createErrorDescriber<
180
256
  );
181
257
  }
182
258
 
259
+ /**
260
+ * Which control to offer, decided once and never switched on separately.
261
+ *
262
+ * Everything below the last line defers to `shouldRetry` — the rule invariant 8 already owns —
263
+ * so the button a reader sees and the retry react-query performs cannot disagree.
264
+ */
265
+ function recoveryFor(error: ApiError, waitSecs: number | null): RecoveryKind {
266
+ // The client raised this one itself, to stop a caller while a dead session was already being
267
+ // handled. Nothing is broken and the person is already on their way to sign-in, so asking
268
+ // them to try again would be the surface contradicting the thing it is reporting.
269
+ if (error.expected) return "none";
270
+ // 401 is "we do not know who you are", which no number of attempts answers. 403 is NOT this:
271
+ // the session is fine and it is a different account that would help, so it falls through.
272
+ if (error.status === 401) return "signin";
273
+ // It named its own expiry, so there is a real number to count down beside a disabled button.
274
+ if (waitSecs !== null) return "wait";
275
+ return shouldRetry(error, durableLimitCodes, maxRetryWaitSecs) ? "retry" : "none";
276
+ }
277
+
183
278
  return function describeError(error: unknown): DescribedError {
184
279
  if (!(error instanceof ApiError)) {
185
280
  // Not a response at all — the request never landed. Almost always the network, and almost
186
- // never worth showing a stack trace for.
187
- return { cause: t(`${copyPrefix}network`), hint: t(`${copyPrefix}networkHint`) };
281
+ // never worth showing a stack trace for. `shouldRetry` answers a non-response the same way,
282
+ // so these two agree here without either being told about the other.
283
+ return {
284
+ cause: t(`${copyPrefix}network`),
285
+ // The catalog KEY keeps its name while the field becomes `fix`: renaming it would cost
286
+ // every adopter a JSON edit in every language, for a string no caller ever types.
287
+ fix: t(`${copyPrefix}networkHint`),
288
+ recover: "retry",
289
+ };
188
290
  }
189
291
 
190
292
  const waitSecs = retryAfterSecs(error);
191
293
  const ctx: ErrorContext = {
192
294
  error,
193
295
  says: serverSentence(error),
194
- wait: waitSecs === null ? null : humanizeWait(t, waitSecs, copyPrefix),
296
+ wait: waitSecs === null ? null : formatWait(waitSecs),
195
297
  waitSecs,
298
+ recover: recoveryFor(error, waitSecs),
196
299
  };
197
300
 
198
- const arm = error.code === undefined ? undefined : codes[error.code];
199
- if (arm) return arm(ctx);
200
-
201
301
  // Anything unmapped: the server's own sentence is still the most specific thing we have,
202
302
  // and support can act on it. A 5xx additionally gets "try again shortly", because that one
203
303
  // genuinely does clear on its own — a 4xx does not, and saying so would be a lie that costs
@@ -207,10 +307,24 @@ export function createErrorDescriber<
207
307
  // own `Request failed (502)`, written for a log. A gateway answering with HTML while the API
208
308
  // restarts is exactly when that happens, so it reached screens — until the second migration,
209
309
  // whose three apps each guarded against that one string by hand.
210
- const serverWords = error.code === undefined ? "" : error.message;
310
+ const arm = error.code === undefined ? undefined : codes[error.code];
311
+ const copy: ErrorCopy = arm
312
+ ? arm(ctx)
313
+ : {
314
+ cause:
315
+ ctx.says ??
316
+ ((error.code === undefined ? "" : error.message) || t(`${copyPrefix}unexpected`)),
317
+ fix: error.status >= 500 ? t(`${copyPrefix}retrySoon`) : undefined,
318
+ };
319
+
320
+ // The arm wins where it spoke, the rule fills the rest. `requestId` is the one thing every
321
+ // error surface asks for and nothing was producing — `ErrorStateProps` has reserved a
322
+ // `reference` slot for it since the contract was written.
323
+ const reference = copy.reference ?? error.requestId;
211
324
  return {
212
- cause: ctx.says ?? (serverWords || t(`${copyPrefix}unexpected`)),
213
- hint: error.status >= 500 ? t(`${copyPrefix}retrySoon`) : undefined,
325
+ ...copy,
326
+ recover: copy.recover ?? ctx.recover,
327
+ ...(reference !== undefined && { reference }),
214
328
  };
215
329
  };
216
330
  }
package/src/index.ts CHANGED
@@ -53,7 +53,9 @@ export {
53
53
  type DescribedError,
54
54
  type ErrorArm,
55
55
  type ErrorContext,
56
+ type ErrorCopy,
56
57
  type ErrorDescriberOptions,
58
+ type RecoveryKind,
57
59
  type Translate,
58
60
  } from "./describe-error.ts";
59
61