@gusnips/react 0.5.3 → 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 +15 -1
- package/dist/describe-error.d.ts +35 -6
- package/dist/describe-error.d.ts.map +1 -1
- package/dist/describe-error.js +11 -4
- package/dist/describe-error.js.map +1 -1
- package/package.json +1 -1
- package/src/describe-error.test.ts +54 -8
- package/src/describe-error.ts +40 -11
package/README.md
CHANGED
|
@@ -156,15 +156,29 @@ hurry cannot skip.
|
|
|
156
156
|
which control to offer, and the request id to print under it:
|
|
157
157
|
|
|
158
158
|
```ts
|
|
159
|
-
const { cause,
|
|
159
|
+
const { cause, fix, recover, reference } = describeError(error);
|
|
160
160
|
// recover: "retry" | "signin" | "wait" | "none"
|
|
161
161
|
```
|
|
162
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
|
+
|
|
163
166
|
`recover` is derived from `shouldRetry` — the same rule react-query retries on — so the button a
|
|
164
167
|
reader sees and the retry that actually happens cannot disagree. Give it the same
|
|
165
168
|
`durableLimitCodes` list you give `queryDefaults`, and a spent quota offers no button instead of
|
|
166
169
|
one that cannot work.
|
|
167
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.
|
|
181
|
+
|
|
168
182
|
## Also here
|
|
169
183
|
|
|
170
184
|
`ErrorBoundary`, an SSE reader split into a platform-free parser and a stream wrapper,
|
package/dist/describe-error.d.ts
CHANGED
|
@@ -8,11 +8,17 @@ 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. `
|
|
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 `
|
|
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.
|
|
@@ -42,7 +48,7 @@ export interface ErrorCopy {
|
|
|
42
48
|
/** What happened, in the reader's words. */
|
|
43
49
|
cause: string;
|
|
44
50
|
/** What to do about it. Its absence should be a decision, not an oversight. */
|
|
45
|
-
|
|
51
|
+
fix?: string;
|
|
46
52
|
/** Override the derived kind — a 429 whose code means a spent quota is `"none"`, not `"wait"`. */
|
|
47
53
|
recover?: RecoveryKind;
|
|
48
54
|
/** Override the request id. Rarely wanted; the envelope's is used by default. */
|
|
@@ -65,8 +71,16 @@ export interface DescribedError extends ErrorCopy {
|
|
|
65
71
|
export type Translate<Key extends string = string> = (key: Key, params?: Record<string, unknown>) => string;
|
|
66
72
|
/** The three names {@link humanizeWait} looks up, under whatever prefix you pass it. */
|
|
67
73
|
type WaitName = "waitSeconds" | "waitMinutes" | "waitHours";
|
|
68
|
-
/**
|
|
69
|
-
|
|
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";
|
|
70
84
|
/**
|
|
71
85
|
* "in 4 minutes" / "in 2 hours" — a wait nobody has to convert from seconds.
|
|
72
86
|
*
|
|
@@ -74,6 +88,11 @@ type CopyName = WaitName | "network" | "networkHint" | "unexpected" | "retrySoon
|
|
|
74
88
|
* not become the less precise "in 1 minute", and 90 minutes rather than 60 for the same reason
|
|
75
89
|
* one rung up. Needs `waitSeconds` / `waitMinutes` / `waitHours` under `prefix`, each with a
|
|
76
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.
|
|
77
96
|
*/
|
|
78
97
|
export declare function humanizeWait<Prefix extends string>(t: Translate<`${Prefix}${WaitName}`>, secs: number, prefix: Prefix): string;
|
|
79
98
|
export interface ErrorContext {
|
|
@@ -95,6 +114,16 @@ export interface ErrorDescriberOptions<Prefix extends string, ServerPrefix exten
|
|
|
95
114
|
t: Translate<`${Prefix}${CopyName}` | `${ServerPrefix}${ServerName}`>;
|
|
96
115
|
/** Catalog namespace for this module's own copy, e.g. `"errors."`. */
|
|
97
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;
|
|
98
127
|
/**
|
|
99
128
|
* The server's own catalog namespace, e.g. `"serverErrors."`. A `messageKey` outside it is
|
|
100
129
|
* ignored — the server names a sentence, it does not get to name any key in the app.
|
|
@@ -138,6 +167,6 @@ export interface ErrorDescriberOptions<Prefix extends string, ServerPrefix exten
|
|
|
138
167
|
}
|
|
139
168
|
export type ErrorArm = (ctx: ErrorContext) => ErrorCopy;
|
|
140
169
|
/** Build the describer. */
|
|
141
|
-
export declare function createErrorDescriber<Prefix extends string, ServerPrefix extends string, ServerName extends string>({ t, copyPrefix, messageKeyPrefix, knownMessageKeys, codes, durableLimitCodes, maxRetryWaitSecs, }: 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;
|
|
142
171
|
export {};
|
|
143
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;AAG1D
|
|
1
|
+
{"version":3,"file":"describe-error.d.ts","sourceRoot":"","sources":["../src/describe-error.ts"],"names":[],"mappings":"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"}
|
package/dist/describe-error.js
CHANGED
|
@@ -7,6 +7,11 @@ import { shouldRetry } from "./query.js";
|
|
|
7
7
|
* not become the less precise "in 1 minute", and 90 minutes rather than 60 for the same reason
|
|
8
8
|
* one rung up. Needs `waitSeconds` / `waitMinutes` / `waitHours` under `prefix`, each with a
|
|
9
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.
|
|
10
15
|
*/
|
|
11
16
|
export function humanizeWait(t, secs, prefix) {
|
|
12
17
|
if (secs < 90)
|
|
@@ -16,7 +21,7 @@ export function humanizeWait(t, secs, prefix) {
|
|
|
16
21
|
return t(`${prefix}waitHours`, { count: Math.round(secs / 3600) });
|
|
17
22
|
}
|
|
18
23
|
/** Build the describer. */
|
|
19
|
-
export function createErrorDescriber({ t, copyPrefix, messageKeyPrefix, knownMessageKeys, codes = {}, durableLimitCodes = [], maxRetryWaitSecs, }) {
|
|
24
|
+
export function createErrorDescriber({ t, copyPrefix, formatWait, messageKeyPrefix, knownMessageKeys, codes = {}, durableLimitCodes = [], maxRetryWaitSecs, }) {
|
|
20
25
|
const known = new Set(Object.keys(knownMessageKeys));
|
|
21
26
|
// Sound, and the reason nothing here needs a cast: the set was built from the keys of
|
|
22
27
|
// `knownMessageKeys`, so membership really does establish the claim. `Object.keys` and not
|
|
@@ -63,7 +68,9 @@ export function createErrorDescriber({ t, copyPrefix, messageKeyPrefix, knownMes
|
|
|
63
68
|
// so these two agree here without either being told about the other.
|
|
64
69
|
return {
|
|
65
70
|
cause: t(`${copyPrefix}network`),
|
|
66
|
-
|
|
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`),
|
|
67
74
|
recover: "retry",
|
|
68
75
|
};
|
|
69
76
|
}
|
|
@@ -71,7 +78,7 @@ export function createErrorDescriber({ t, copyPrefix, messageKeyPrefix, knownMes
|
|
|
71
78
|
const ctx = {
|
|
72
79
|
error,
|
|
73
80
|
says: serverSentence(error),
|
|
74
|
-
wait: waitSecs === null ? null :
|
|
81
|
+
wait: waitSecs === null ? null : formatWait(waitSecs),
|
|
75
82
|
waitSecs,
|
|
76
83
|
recover: recoveryFor(error, waitSecs),
|
|
77
84
|
};
|
|
@@ -90,7 +97,7 @@ export function createErrorDescriber({ t, copyPrefix, messageKeyPrefix, knownMes
|
|
|
90
97
|
: {
|
|
91
98
|
cause: ctx.says ??
|
|
92
99
|
((error.code === undefined ? "" : error.message) || t(`${copyPrefix}unexpected`)),
|
|
93
|
-
|
|
100
|
+
fix: error.status >= 500 ? t(`${copyPrefix}retrySoon`) : undefined,
|
|
94
101
|
};
|
|
95
102
|
// The arm wins where it spoke, the rule fills the rest. `requestId` is the one thing every
|
|
96
103
|
// error surface asks for and nothing was producing — `ErrorStateProps` has reserved a
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"describe-error.js","sourceRoot":"","sources":["../src/describe-error.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAC1D,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"describe-error.js","sourceRoot":"","sources":["../src/describe-error.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gusnips/react",
|
|
3
|
-
"version": "0.
|
|
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",
|
|
@@ -23,25 +23,68 @@ describe("humanizeWait", () => {
|
|
|
23
23
|
});
|
|
24
24
|
});
|
|
25
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
|
+
|
|
26
68
|
describe("createErrorDescriber", () => {
|
|
27
69
|
const describeError = createErrorDescriber({
|
|
28
70
|
t,
|
|
29
71
|
copyPrefix: "errors.",
|
|
72
|
+
formatWait: (secs) => humanizeWait(t, secs, "errors."),
|
|
30
73
|
messageKeyPrefix: "serverErrors.",
|
|
31
74
|
knownMessageKeys: { quotaDay: "", suspended: "" },
|
|
32
75
|
codes: {
|
|
33
76
|
QUOTA_EXCEEDED: ({ says, wait }) => ({
|
|
34
77
|
cause: says ?? "errors.quota",
|
|
35
|
-
|
|
78
|
+
fix: wait ? `errors.retryIn(${wait})` : undefined,
|
|
36
79
|
}),
|
|
37
80
|
},
|
|
38
81
|
});
|
|
39
82
|
|
|
40
83
|
it("reads a thrown non-response as the network, with a way forward", () => {
|
|
41
|
-
const { cause,
|
|
84
|
+
const { cause, fix } = describeError(new TypeError("Failed to fetch"));
|
|
42
85
|
expect(cause).toBe("errors.network");
|
|
43
|
-
// The
|
|
44
|
-
expect(
|
|
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");
|
|
45
88
|
});
|
|
46
89
|
|
|
47
90
|
it("resolves a messageKey this build carries, with its params", () => {
|
|
@@ -82,16 +125,16 @@ describe("createErrorDescriber", () => {
|
|
|
82
125
|
it("offers a retry on a 5xx and withholds it on a 4xx", () => {
|
|
83
126
|
// A 5xx genuinely clears on its own. A 4xx does not, and saying so would cost the reader
|
|
84
127
|
// another attempt for nothing.
|
|
85
|
-
expect(describeError(new ApiError(503, null)).
|
|
86
|
-
expect(describeError(new ApiError(403, null)).
|
|
128
|
+
expect(describeError(new ApiError(503, null)).fix).toBe("errors.retrySoon");
|
|
129
|
+
expect(describeError(new ApiError(403, null)).fix).toBeUndefined();
|
|
87
130
|
});
|
|
88
131
|
|
|
89
132
|
it("never puts the client's own log line on screen", () => {
|
|
90
133
|
// A gateway answers with HTML, so there is no envelope and `message` is the client's
|
|
91
134
|
// `Request failed (502)` — English, and written for a log.
|
|
92
|
-
const { cause,
|
|
135
|
+
const { cause, fix } = describeError(new ApiError(502, null));
|
|
93
136
|
expect(cause).toBe("errors.unexpected");
|
|
94
|
-
expect(
|
|
137
|
+
expect(fix).toBe("errors.retrySoon");
|
|
95
138
|
});
|
|
96
139
|
});
|
|
97
140
|
|
|
@@ -107,6 +150,7 @@ describe("the recovery kind", () => {
|
|
|
107
150
|
messageKeyPrefix: "serverErrors.",
|
|
108
151
|
knownMessageKeys: { quotaDay: "" },
|
|
109
152
|
durableLimitCodes: ["QUOTA_EXCEEDED"],
|
|
153
|
+
formatWait: (secs) => humanizeWait(t, secs, "errors."),
|
|
110
154
|
});
|
|
111
155
|
|
|
112
156
|
it("sends a 401 to sign-in and leaves a 403 alone", () => {
|
|
@@ -159,6 +203,7 @@ describe("the recovery kind", () => {
|
|
|
159
203
|
const describe503 = createErrorDescriber({
|
|
160
204
|
t,
|
|
161
205
|
copyPrefix: "errors.",
|
|
206
|
+
formatWait: (secs) => humanizeWait(t, secs, "errors."),
|
|
162
207
|
messageKeyPrefix: "serverErrors.",
|
|
163
208
|
knownMessageKeys: {},
|
|
164
209
|
codes: {
|
|
@@ -185,6 +230,7 @@ describe("createErrorDescriber, when the code names the sentence", () => {
|
|
|
185
230
|
const describeError = createErrorDescriber({
|
|
186
231
|
t,
|
|
187
232
|
copyPrefix: "errors.",
|
|
233
|
+
formatWait: (secs) => humanizeWait(t, secs, "errors."),
|
|
188
234
|
messageKeyPrefix: "errors.",
|
|
189
235
|
knownMessageKeys: { FORECAST_NOT_FOUND: "", network: "" },
|
|
190
236
|
});
|
package/src/describe-error.ts
CHANGED
|
@@ -10,11 +10,17 @@ import { shouldRetry } from "./query.ts";
|
|
|
10
10
|
* "Monthly quota spent, upgrade or buy a pack" is right in a JSON body and wrong under a button
|
|
11
11
|
* that could just say what to do next.
|
|
12
12
|
*
|
|
13
|
-
* `cause` is what happened. `
|
|
13
|
+
* `cause` is what happened. `fix` is what to do about it. **A surface that shows only the first
|
|
14
14
|
* half is the dead end this exists to prevent** — which is why the type has two fields and not
|
|
15
|
-
* one, and why `
|
|
15
|
+
* one, and why `fix` being absent should be a deliberate choice at each call site rather than
|
|
16
16
|
* the default nobody noticed.
|
|
17
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
|
+
*
|
|
18
24
|
* The per-code switch stays in the product: two donors' switches shared their SHAPE and almost
|
|
19
25
|
* no arms, because the arms are that API's vocabulary. What ships is the scaffolding around it,
|
|
20
26
|
* which is the part that was written twice and got subtly different both times.
|
|
@@ -46,7 +52,7 @@ export interface ErrorCopy {
|
|
|
46
52
|
/** What happened, in the reader's words. */
|
|
47
53
|
cause: string;
|
|
48
54
|
/** What to do about it. Its absence should be a decision, not an oversight. */
|
|
49
|
-
|
|
55
|
+
fix?: string;
|
|
50
56
|
/** Override the derived kind — a 429 whose code means a spent quota is `"none"`, not `"wait"`. */
|
|
51
57
|
recover?: RecoveryKind;
|
|
52
58
|
/** Override the request id. Rarely wanted; the envelope's is used by default. */
|
|
@@ -76,8 +82,16 @@ export type Translate<Key extends string = string> = (
|
|
|
76
82
|
/** The three names {@link humanizeWait} looks up, under whatever prefix you pass it. */
|
|
77
83
|
type WaitName = "waitSeconds" | "waitMinutes" | "waitHours";
|
|
78
84
|
|
|
79
|
-
/**
|
|
80
|
-
|
|
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";
|
|
81
95
|
|
|
82
96
|
/**
|
|
83
97
|
* The paragraph above, pinned so it cannot quietly stop being true.
|
|
@@ -94,9 +108,6 @@ type ClosedCatalog = (
|
|
|
94
108
|
| "errors.networkHint"
|
|
95
109
|
| "errors.unexpected"
|
|
96
110
|
| "errors.retrySoon"
|
|
97
|
-
| "errors.waitSeconds"
|
|
98
|
-
| "errors.waitMinutes"
|
|
99
|
-
| "errors.waitHours"
|
|
100
111
|
| "serverErrors.quotaDay"
|
|
101
112
|
| "billing.plan",
|
|
102
113
|
params?: Record<string, unknown>,
|
|
@@ -114,6 +125,11 @@ type _TypedCatalogNeedsNoCast = Satisfied<
|
|
|
114
125
|
* not become the less precise "in 1 minute", and 90 minutes rather than 60 for the same reason
|
|
115
126
|
* one rung up. Needs `waitSeconds` / `waitMinutes` / `waitHours` under `prefix`, each with a
|
|
116
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.
|
|
117
133
|
*/
|
|
118
134
|
export function humanizeWait<Prefix extends string>(
|
|
119
135
|
t: Translate<`${Prefix}${WaitName}`>,
|
|
@@ -149,6 +165,16 @@ export interface ErrorDescriberOptions<
|
|
|
149
165
|
t: Translate<`${Prefix}${CopyName}` | `${ServerPrefix}${ServerName}`>;
|
|
150
166
|
/** Catalog namespace for this module's own copy, e.g. `"errors."`. */
|
|
151
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;
|
|
152
178
|
/**
|
|
153
179
|
* The server's own catalog namespace, e.g. `"serverErrors."`. A `messageKey` outside it is
|
|
154
180
|
* ignored — the server names a sentence, it does not get to name any key in the app.
|
|
@@ -201,6 +227,7 @@ export function createErrorDescriber<
|
|
|
201
227
|
>({
|
|
202
228
|
t,
|
|
203
229
|
copyPrefix,
|
|
230
|
+
formatWait,
|
|
204
231
|
messageKeyPrefix,
|
|
205
232
|
knownMessageKeys,
|
|
206
233
|
codes = {},
|
|
@@ -255,7 +282,9 @@ export function createErrorDescriber<
|
|
|
255
282
|
// so these two agree here without either being told about the other.
|
|
256
283
|
return {
|
|
257
284
|
cause: t(`${copyPrefix}network`),
|
|
258
|
-
|
|
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`),
|
|
259
288
|
recover: "retry",
|
|
260
289
|
};
|
|
261
290
|
}
|
|
@@ -264,7 +293,7 @@ export function createErrorDescriber<
|
|
|
264
293
|
const ctx: ErrorContext = {
|
|
265
294
|
error,
|
|
266
295
|
says: serverSentence(error),
|
|
267
|
-
wait: waitSecs === null ? null :
|
|
296
|
+
wait: waitSecs === null ? null : formatWait(waitSecs),
|
|
268
297
|
waitSecs,
|
|
269
298
|
recover: recoveryFor(error, waitSecs),
|
|
270
299
|
};
|
|
@@ -285,7 +314,7 @@ export function createErrorDescriber<
|
|
|
285
314
|
cause:
|
|
286
315
|
ctx.says ??
|
|
287
316
|
((error.code === undefined ? "" : error.message) || t(`${copyPrefix}unexpected`)),
|
|
288
|
-
|
|
317
|
+
fix: error.status >= 500 ? t(`${copyPrefix}retrySoon`) : undefined,
|
|
289
318
|
};
|
|
290
319
|
|
|
291
320
|
// The arm wins where it spoke, the rule fills the rest. `requestId` is the one thing every
|