@pithy-sh/cloudflare 0.1.6 → 0.2.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/dist/client/errors.d.ts +144 -2
- package/dist/client/errors.d.ts.map +1 -1
- package/dist/client/errors.js +416 -7
- package/dist/media/assetSeeder.d.ts.map +1 -1
- package/dist/media/assetSeeder.js +19 -5
- package/dist/test-utils/emailRoutingRules.d.ts.map +1 -1
- package/dist/test-utils/emailRoutingRules.js +19 -6
- package/dist/test-utils/inboundRecorder.d.ts.map +1 -1
- package/dist/test-utils/inboundRecorder.js +19 -6
- package/dist/tokens/accountTokensManager.d.ts.map +1 -1
- package/dist/tokens/accountTokensManager.js +40 -17
- package/dist/workers/buildsManager.d.ts.map +1 -1
- package/dist/workers/buildsManager.js +9 -5
- package/dist/workers/workersManager.d.ts +19 -0
- package/dist/workers/workersManager.d.ts.map +1 -1
- package/dist/workers/workersManager.js +33 -1
- package/dist/workflows/stepFailure.d.ts +28 -0
- package/dist/workflows/stepFailure.d.ts.map +1 -1
- package/dist/workflows/stepFailure.js +44 -2
- package/package.json +6 -5
- package/src/client/errors.ts +497 -8
- package/src/media/assetSeeder.ts +22 -4
- package/src/test-utils/emailRoutingRules.ts +19 -5
- package/src/test-utils/inboundRecorder.ts +19 -5
- package/src/tokens/accountTokensManager.ts +118 -54
- package/src/workers/buildsManager.ts +21 -6
- package/src/workers/workersManager.ts +41 -1
- package/src/workflows/stepFailure.ts +44 -1
package/dist/client/errors.d.ts
CHANGED
|
@@ -30,7 +30,16 @@ export declare class CloudflareNotConfiguredError extends PithyError {
|
|
|
30
30
|
cause?: unknown;
|
|
31
31
|
});
|
|
32
32
|
}
|
|
33
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* A call to the Cloudflare REST API failed (network error, 4xx/5xx from the API).
|
|
35
|
+
*
|
|
36
|
+
* **The `apiAnswer` default is the guarantee, not a convenience.** `core`'s `GUARANTEED_ERROR_PARAMS`
|
|
37
|
+
* declares that every throw of `cloudflare/request_failed` passes `apiAnswer`, and a locale wrote a
|
|
38
|
+
* `{apiAnswer}` against that declaration — so a path that omits it renders the placeholder literally on
|
|
39
|
+
* a Spanish reader's screen. Guaranteed means every throw, so the promise is kept where the code is
|
|
40
|
+
* fixed rather than at each of the sites that construct one: an empty answer is what "the call never
|
|
41
|
+
* reached Cloudflare" renders as, and it is the honest value for any path that has nothing to report.
|
|
42
|
+
*/
|
|
34
43
|
export declare class CloudflareRequestError extends PithyError {
|
|
35
44
|
constructor(args?: CloudflareErrorArgs, options?: {
|
|
36
45
|
cause?: unknown;
|
|
@@ -42,13 +51,141 @@ export declare class CloudflareInvalidResponseError extends PithyError {
|
|
|
42
51
|
cause?: unknown;
|
|
43
52
|
});
|
|
44
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* One entry of Cloudflare's `errors[]` array — the API's own answer to a failed call.
|
|
56
|
+
*
|
|
57
|
+
* Every v4 endpoint answers a failure with `{ success: false, errors: [{ code, message,
|
|
58
|
+
* documentation_url }] }`, and the SDK already parses it onto the thrown `APIError`. Until #534 we
|
|
59
|
+
* projected none of it: a refusal named the operation and stopped, so a token missing one product's
|
|
60
|
+
* grant, a revoked token, a wrong account and an outage all rendered as the same sentence.
|
|
61
|
+
*/
|
|
62
|
+
export interface CloudflareApiError {
|
|
63
|
+
/** Cloudflare's numeric error code — the stable discriminator (`10000` is its auth-class code). */
|
|
64
|
+
code?: number;
|
|
65
|
+
/** Cloudflare's own sentence for the failure ("Authentication error"). */
|
|
66
|
+
message?: string;
|
|
67
|
+
/** The docs page for the endpoint that refused, when Cloudflare names one. */
|
|
68
|
+
documentationUrl?: string;
|
|
69
|
+
}
|
|
45
70
|
/**
|
|
46
71
|
* Run a Cloudflare SDK call and turn any failure into a `CloudflareRequestError`, preserving the
|
|
47
72
|
* original error as `cause` and its message as internal `detail`. `operation` names the call for
|
|
48
73
|
* the audit trail (`"KV get for key 'x'"`). A `PithyError` already in flight (e.g. a
|
|
49
74
|
* not-configured guard) passes through untouched — only foreign throws get wrapped.
|
|
75
|
+
*
|
|
76
|
+
* The refusal carries Cloudflare's own `errors[]` — see {@link cloudflareRefusal}.
|
|
77
|
+
*
|
|
78
|
+
* **A call that ran out of time is `core/upstream_timeout` (504), not a 502.** CLAUDE.md §Errors
|
|
79
|
+
* splits the upstream class in two for a reason a retry policy acts on: a 502 says Cloudflare
|
|
80
|
+
* answered and refused, so the same call refuses again; a 504 says nobody answered, so the same call
|
|
81
|
+
* may well succeed — **and may already have been applied**, which is the fact a caller needs before it
|
|
82
|
+
* retries a create. Folding a connect timeout into `cloudflare/request_failed` told every reader the
|
|
83
|
+
* first story about the second event.
|
|
84
|
+
*
|
|
85
|
+
* `permission` names the account permission group behind this endpoint for the refusal's action line,
|
|
86
|
+
* for the endpoints whose docs link does not say. It is the same fact `cloudflareRefusal` takes, handed
|
|
87
|
+
* in here so the *first* call of a command can carry it: `pithy token mint` refuses at
|
|
88
|
+
* `findTokenByName` and `listPermissionGroups`, both of which run before the mint, so a hint attached
|
|
89
|
+
* only to the mint is a hint the operator never reaches.
|
|
90
|
+
*/
|
|
91
|
+
export declare function cloudflareRequest<T>(operation: string, fn: () => Promise<T>, options?: {
|
|
92
|
+
permission?: string;
|
|
93
|
+
}): Promise<T>;
|
|
94
|
+
/**
|
|
95
|
+
* Compose the refusal every failed Cloudflare call renders: the problem line, then the API's own
|
|
96
|
+
* answer, then — only for an auth-class code — what to do about it.
|
|
97
|
+
*
|
|
98
|
+
* One place, so a fix reaches every provisioner at once — and **one place is a claim a test has to
|
|
99
|
+
* hold, because it was false the first time it was written.** Round one of #534 said
|
|
100
|
+
* `CloudflareBuildsManager` was the only path composing a refusal by hand; `mintToken`, the Stream
|
|
101
|
+
* direct upload and two integration helpers were composing their own, so `pithy token mint` — the
|
|
102
|
+
* bootstrap command an under-scoped token meets first — still printed the operation and nothing else
|
|
103
|
+
* on the identical 401 body. `refusalSites.test.ts` now fails any `new CloudflareRequestError(`
|
|
104
|
+
* outside this file, so "the only place" is enforced rather than remembered.
|
|
105
|
+
*
|
|
106
|
+
* CLAUDE.md §Errors decides where each string lands. Cloudflare's sentence and its docs link are the
|
|
107
|
+
* **caller's** information — what happened, in the words of whoever it happened to — so they go in
|
|
108
|
+
* `message`, field by field off `errors[]` and never as `JSON.stringify(body)`: these managers are
|
|
109
|
+
* imported into Worker paths too, so `message` can reach a browser through `clientError`. The
|
|
110
|
+
* structured `code` and link ride along in `params`, which is client-facing by definition, so a
|
|
111
|
+
* translating client can render them under the error code. "Add this grant, then re-run" names a
|
|
112
|
+
* console the operator is sitting in front of, so it is an `action`. The raw body stays `detail`.
|
|
113
|
+
*
|
|
114
|
+
* The code stays `cloudflare/request_failed` at 502: a dedicated sibling of `core/upstream_failed`,
|
|
115
|
+
* already the upstream class CLAUDE.md asks for, and pinned by `core`'s payload test.
|
|
116
|
+
*
|
|
117
|
+
* **The message is one line, and that is a kit-wide rule this had to learn the hard way.** Round one
|
|
118
|
+
* of #534 made it a small document — problem line, `errors[]` indented beneath, action last — which
|
|
119
|
+
* reads beautifully in a terminal and is unencodable everywhere else. A `PithyError` raised inside a
|
|
120
|
+
* `classifiedSteps` step crosses a durable boundary as one string whose newline separates the sentence
|
|
121
|
+
* from the remedy, so a multi-line `message` was declined whole at the reader and the operator got
|
|
122
|
+
* `core/workflow_failed` 500 about durable execution instead of the API answer this issue exists to
|
|
123
|
+
* show them. The same newline is what the CLI's action line is, so an upstream sentence containing one
|
|
124
|
+
* forges a remedy Pithy never wrote. `Cloudflare said:` marks the boundary between our words and
|
|
125
|
+
* theirs with a word instead of an indent, and a word survives a step record, a `--json` line and a
|
|
126
|
+
* browser.
|
|
127
|
+
*/
|
|
128
|
+
export declare function cloudflareRefusal(args: {
|
|
129
|
+
/** The problem line — what Pithy was doing when the API refused. */
|
|
130
|
+
problem: string;
|
|
131
|
+
/** Cloudflare's own error entries, from {@link cloudflareApiErrors}. */
|
|
132
|
+
apiErrors: CloudflareApiError[];
|
|
133
|
+
/** The HTTP status, when one is known. A fallback signal only; the code is the discriminator. */
|
|
134
|
+
status?: number;
|
|
135
|
+
/**
|
|
136
|
+
* The account permission group behind this endpoint, when the call site knows it and Cloudflare's
|
|
137
|
+
* docs link will not say. A **fact**, not wording: the sentence is still composed here, and a hint
|
|
138
|
+
* that disagrees with the link loses to the link. `undefined` yields the generic action, which is
|
|
139
|
+
* right — a wrong grant named confidently is worse than none.
|
|
140
|
+
*/
|
|
141
|
+
permission?: string;
|
|
142
|
+
/** The raw failure text, for logs and audit alone. */
|
|
143
|
+
detail: string;
|
|
144
|
+
/** The original throw. */
|
|
145
|
+
cause?: unknown;
|
|
146
|
+
}): CloudflareRequestError;
|
|
147
|
+
/**
|
|
148
|
+
* Pithy's problem line with Cloudflare's own answer on the end of it, and the same answer as `params`.
|
|
149
|
+
*
|
|
150
|
+
* Exported because {@link cloudflareRefusal} is not the only refusal that must carry it. The 403 on
|
|
151
|
+
* `accounts/tokens create` says something a generic refusal cannot — the token got in and may not
|
|
152
|
+
* create tokens — so it keeps its own diagnosis and its own code, and *that* is what made it drop
|
|
153
|
+
* Cloudflare's answer for two rounds: a call site with a better sentence had no way to keep the API's
|
|
154
|
+
* words. Now it composes the sentence here like everything else, and the only thing it supplies is the
|
|
155
|
+
* problem line.
|
|
156
|
+
*
|
|
157
|
+
* One line, always. Whitespace runs collapse and entries join with a semicolon. The problem line is
|
|
158
|
+
* flattened along with the rest because a call site interpolates values into it (`KV get for key
|
|
159
|
+
* '<key>'`), and a key holding a newline is an operator's refusal wearing a forged action line.
|
|
160
|
+
*
|
|
161
|
+
* **What gets cut when it does not fit is the part Pithy can afford to lose.** The problem line is
|
|
162
|
+
* bounded to {@link MAX_PROBLEM_LINE} first, so a 512-byte KV key or a 1024-byte R2 one cannot crowd out
|
|
163
|
+
* the answer; then whole entries are dropped from the end until the sentence fits
|
|
164
|
+
* {@link MAX_REFUSAL_MESSAGE}, the bound of the durable step channel. Slicing the composed sentence
|
|
165
|
+
* instead — which is what this did — discards the tail, and the tail is always Cloudflare's answer.
|
|
166
|
+
*/
|
|
167
|
+
export declare function cloudflareSaid(problem: string, apiErrors: CloudflareApiError[]): {
|
|
168
|
+
message: string;
|
|
169
|
+
params: MessageParams;
|
|
170
|
+
};
|
|
171
|
+
/**
|
|
172
|
+
* Cloudflare's `errors[]`, read off a thrown SDK error (`APIError.errors`) or handed the array
|
|
173
|
+
* straight from an unwrapped envelope. Duck-typed rather than `instanceof APIError`, matching
|
|
174
|
+
* {@link isNotFoundError}: the SDK is one producer of this shape and the raw-`fetch` path is another.
|
|
175
|
+
*
|
|
176
|
+
* Entries are read field by field and dropped when they carry neither a code nor a sentence, so a
|
|
177
|
+
* malformed body degrades to today's refusal instead of rendering an empty line. A
|
|
178
|
+
* `documentation_url` is kept only when it is `https:` — the link is printed, and a printed link is
|
|
179
|
+
* one somebody clicks.
|
|
180
|
+
*/
|
|
181
|
+
export declare function cloudflareApiErrors(source: unknown): CloudflareApiError[];
|
|
182
|
+
/**
|
|
183
|
+
* The HTTP status of a thrown SDK error, when it carries one. Exported because a call site that
|
|
184
|
+
* composes its own refusal through {@link cloudflareRefusal} has to hand it the same status this
|
|
185
|
+
* module would have read — a hand-rolled `(error as { status?: number }).status` beside it is the
|
|
186
|
+
* duplicate that drifts.
|
|
50
187
|
*/
|
|
51
|
-
export declare function
|
|
188
|
+
export declare function statusOf(error: unknown): number | undefined;
|
|
52
189
|
/** The raw message of an unknown throw, for use as a PithyError `detail`. One source of truth. */
|
|
53
190
|
export declare function messageOf(error: unknown): string;
|
|
54
191
|
/**
|
|
@@ -63,6 +200,11 @@ export declare function isNotFoundError(error: unknown): boolean;
|
|
|
63
200
|
* Whether a thrown SDK error is an HTTP 403 — the token reached the API but lacks the permission for
|
|
64
201
|
* the operation. The signal a caller uses to turn a raw "Unauthorized" into an actionable
|
|
65
202
|
* "grant this permission group" message instead of a generic request failure.
|
|
203
|
+
*
|
|
204
|
+
* Strictly 403, and it stays that way: its callers branch on it to *swallow* a denial
|
|
205
|
+
* (`getTokenName` answers `null`), so widening it to 401 would report a dead token as a permission
|
|
206
|
+
* this project does not need. The broader "did the credentials get in" question is
|
|
207
|
+
* {@link AUTH_CLASS_CODES}, which reads the API's own code rather than the status.
|
|
66
208
|
*/
|
|
67
209
|
export declare function isAuthorizationError(error: unknown): boolean;
|
|
68
210
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/client/errors.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,UAAU,
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/client/errors.ts"],"names":[],"mappings":"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"}
|
package/dist/client/errors.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
|
-
import { PithyError } from "@pithy-sh/core/src/error/pithyError";
|
|
3
|
+
import { PithyError, UpstreamTimeoutError } from "@pithy-sh/core/src/error/pithyError";
|
|
4
|
+
import { MAX_WORKFLOW_STEP_TEXT } from "@pithy-sh/core/src/workflow/stepMessage";
|
|
4
5
|
//#region src/client/errors.ts
|
|
5
6
|
/** A required piece of configuration (token, account id, resource id) is missing. */
|
|
6
7
|
var CloudflareNotConfiguredError = class extends PithyError {
|
|
@@ -15,7 +16,16 @@ var CloudflareNotConfiguredError = class extends PithyError {
|
|
|
15
16
|
}, options);
|
|
16
17
|
}
|
|
17
18
|
};
|
|
18
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* A call to the Cloudflare REST API failed (network error, 4xx/5xx from the API).
|
|
21
|
+
*
|
|
22
|
+
* **The `apiAnswer` default is the guarantee, not a convenience.** `core`'s `GUARANTEED_ERROR_PARAMS`
|
|
23
|
+
* declares that every throw of `cloudflare/request_failed` passes `apiAnswer`, and a locale wrote a
|
|
24
|
+
* `{apiAnswer}` against that declaration — so a path that omits it renders the placeholder literally on
|
|
25
|
+
* a Spanish reader's screen. Guaranteed means every throw, so the promise is kept where the code is
|
|
26
|
+
* fixed rather than at each of the sites that construct one: an empty answer is what "the call never
|
|
27
|
+
* reached Cloudflare" renders as, and it is the honest value for any path that has nothing to report.
|
|
28
|
+
*/
|
|
19
29
|
var CloudflareRequestError = class extends PithyError {
|
|
20
30
|
constructor(args = {}, options) {
|
|
21
31
|
super({
|
|
@@ -24,7 +34,10 @@ var CloudflareRequestError = class extends PithyError {
|
|
|
24
34
|
message: args.message ?? "A Cloudflare REST API call failed.",
|
|
25
35
|
action: args.action,
|
|
26
36
|
detail: args.detail,
|
|
27
|
-
params:
|
|
37
|
+
params: {
|
|
38
|
+
apiAnswer: "",
|
|
39
|
+
...args.params
|
|
40
|
+
}
|
|
28
41
|
}, options);
|
|
29
42
|
}
|
|
30
43
|
};
|
|
@@ -42,21 +55,412 @@ var CloudflareInvalidResponseError = class extends PithyError {
|
|
|
42
55
|
}
|
|
43
56
|
};
|
|
44
57
|
/**
|
|
58
|
+
* How many of Cloudflare's error entries reach the refusal, and how long each sentence may be.
|
|
59
|
+
*
|
|
60
|
+
* Bounded because this text lands in `message`, and `message` is the one field that crosses to a
|
|
61
|
+
* client (CLAUDE.md §Errors). An operator gets the whole body in `detail`; a refusal stays readable.
|
|
62
|
+
*
|
|
63
|
+
* Bounded is not the same as **shaped**, which is why {@link oneLine} exists beside these two. A `\n`
|
|
64
|
+
* inside Cloudflare's sentence would put the remainder at column zero, indistinguishable from Pithy's
|
|
65
|
+
* own action line, and would cross to a browser exactly as written. So upstream text is flattened to
|
|
66
|
+
* one line before it is measured: sanitize, then slice, never the reverse.
|
|
67
|
+
*/
|
|
68
|
+
const MAX_API_ERRORS = 3;
|
|
69
|
+
const MAX_API_MESSAGE = 200;
|
|
70
|
+
/**
|
|
71
|
+
* The most a composed refusal `message` may run to, and it is **derived, not chosen**.
|
|
72
|
+
*
|
|
73
|
+
* A refusal is raised inside Workflow steps as well as at a CLI — `classifiedSteps` wraps the store
|
|
74
|
+
* calls in `@pithy-sh/secrets`' rotation, `@pithy-sh/media`'s Stream reads, `@pithy-sh/payments`'
|
|
75
|
+
* reconcile — and across that durable boundary the step's text is the whole channel, measured against
|
|
76
|
+
* core's `MAX_WORKFLOW_STEP_TEXT`. The encoder truncates past it rather than losing the field, so this
|
|
77
|
+
* is not a guard against disaster; it is the reason the operator's terminal and the step record say the
|
|
78
|
+
* same sentence. A refusal composed to a bound nobody else holds would be cut somewhere else.
|
|
79
|
+
*/
|
|
80
|
+
const MAX_REFUSAL_MESSAGE = MAX_WORKFLOW_STEP_TEXT;
|
|
81
|
+
/**
|
|
82
|
+
* The most the **problem line** may run to before the answer is composed after it.
|
|
83
|
+
*
|
|
84
|
+
* **Truncating the composed sentence is not enough, because it cuts the wrong half.** A call site
|
|
85
|
+
* interpolates caller data into the problem — `KV get for key '<key>'`, `R2 copy '<src>' to '<dst>'` —
|
|
86
|
+
* and Cloudflare permits a 512-byte KV key and a 1024-byte R2 one, two per label. A head-preserving
|
|
87
|
+
* slice at {@link MAX_REFUSAL_MESSAGE} therefore discards Pithy's *and* Cloudflare's words in that
|
|
88
|
+
* order, so a long key silently ate the API answer this whole path exists to surface: at a 512-char
|
|
89
|
+
* key the message kept neither the code nor the link, and ended in 59 characters of key.
|
|
90
|
+
*
|
|
91
|
+
* The problem line is bounded first, so the answer always has room. 200 is generous for every operation
|
|
92
|
+
* label in the repository and still leaves the greater part of the budget to Cloudflare.
|
|
93
|
+
*/
|
|
94
|
+
const MAX_PROBLEM_LINE = 200;
|
|
95
|
+
/** `text`, or as much of it as `limit` allows with a trailing ellipsis standing for the rest. */
|
|
96
|
+
function bounded(text, limit) {
|
|
97
|
+
return text.length <= limit ? text : `${text.slice(0, limit - 1).trimEnd()}…`;
|
|
98
|
+
}
|
|
99
|
+
/** Upstream text as one line: every whitespace run — newline, tab, CR — collapsed to a single space. */
|
|
100
|
+
function oneLine(text) {
|
|
101
|
+
return text.replace(/\s+/g, " ").trim();
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Cloudflare's auth-class codes — the ones that mean "your credentials did not get you in", as
|
|
105
|
+
* opposed to a missing resource (`12000`), a conflict (`12042`) or a rejected argument (`10202`).
|
|
106
|
+
*
|
|
107
|
+
* Status alone is not the discriminator, which is the trap #534 walked into: the reported Turnstile
|
|
108
|
+
* refusal was `10000` under a **401**, while `isAuthorizationError` below keys on 403 and would not
|
|
109
|
+
* have fired. Cloudflare returns `10000` under 400, 401 and 403 depending on the endpoint, so the
|
|
110
|
+
* code is what identifies the class and the status is only a fallback for a body-less throw.
|
|
111
|
+
*/
|
|
112
|
+
const AUTH_CLASS_CODES = /* @__PURE__ */ new Set([
|
|
113
|
+
9103,
|
|
114
|
+
9106,
|
|
115
|
+
9107,
|
|
116
|
+
9109,
|
|
117
|
+
1e4,
|
|
118
|
+
10001
|
|
119
|
+
]);
|
|
120
|
+
/**
|
|
121
|
+
* Cloudflare API resource segment → the account permission group whose absence explains a refusal.
|
|
122
|
+
*
|
|
123
|
+
* The product name comes from Cloudflare's own `documentation_url`
|
|
124
|
+
* (`/api/resources/turnstile/subresources/widgets/methods/list` → `turnstile`), not from the
|
|
125
|
+
* `operation` string a call site wrote: roughly a third of those never name the product, and the
|
|
126
|
+
* manager that does know (`getServiceType()`) is a `this` no free function sees. Deriving it from the
|
|
127
|
+
* failure itself is what lets all 134 call sites improve without one of them changing.
|
|
128
|
+
*
|
|
129
|
+
* A segment absent here yields no product name and the generic action — a wrong grant named
|
|
130
|
+
* confidently is worse than none. Verify a new row against the dashboard's token editor before
|
|
131
|
+
* adding it.
|
|
132
|
+
*/
|
|
133
|
+
const ACCOUNT_PERMISSIONS = {
|
|
134
|
+
ai: "Workers AI",
|
|
135
|
+
d1: "D1",
|
|
136
|
+
images: "Cloudflare Images",
|
|
137
|
+
kv: "Workers KV Storage",
|
|
138
|
+
queues: "Queues",
|
|
139
|
+
r2: "Workers R2 Storage",
|
|
140
|
+
secrets_store: "Secrets Store",
|
|
141
|
+
stream: "Stream",
|
|
142
|
+
turnstile: "Turnstile",
|
|
143
|
+
vectorize: "Vectorize",
|
|
144
|
+
workers: "Workers Scripts"
|
|
145
|
+
};
|
|
146
|
+
/**
|
|
45
147
|
* Run a Cloudflare SDK call and turn any failure into a `CloudflareRequestError`, preserving the
|
|
46
148
|
* original error as `cause` and its message as internal `detail`. `operation` names the call for
|
|
47
149
|
* the audit trail (`"KV get for key 'x'"`). A `PithyError` already in flight (e.g. a
|
|
48
150
|
* not-configured guard) passes through untouched — only foreign throws get wrapped.
|
|
151
|
+
*
|
|
152
|
+
* The refusal carries Cloudflare's own `errors[]` — see {@link cloudflareRefusal}.
|
|
153
|
+
*
|
|
154
|
+
* **A call that ran out of time is `core/upstream_timeout` (504), not a 502.** CLAUDE.md §Errors
|
|
155
|
+
* splits the upstream class in two for a reason a retry policy acts on: a 502 says Cloudflare
|
|
156
|
+
* answered and refused, so the same call refuses again; a 504 says nobody answered, so the same call
|
|
157
|
+
* may well succeed — **and may already have been applied**, which is the fact a caller needs before it
|
|
158
|
+
* retries a create. Folding a connect timeout into `cloudflare/request_failed` told every reader the
|
|
159
|
+
* first story about the second event.
|
|
160
|
+
*
|
|
161
|
+
* `permission` names the account permission group behind this endpoint for the refusal's action line,
|
|
162
|
+
* for the endpoints whose docs link does not say. It is the same fact `cloudflareRefusal` takes, handed
|
|
163
|
+
* in here so the *first* call of a command can carry it: `pithy token mint` refuses at
|
|
164
|
+
* `findTokenByName` and `listPermissionGroups`, both of which run before the mint, so a hint attached
|
|
165
|
+
* only to the mint is a hint the operator never reaches.
|
|
49
166
|
*/
|
|
50
|
-
async function cloudflareRequest(operation, fn) {
|
|
167
|
+
async function cloudflareRequest(operation, fn, options) {
|
|
51
168
|
try {
|
|
52
169
|
return await fn();
|
|
53
170
|
} catch (error) {
|
|
54
171
|
if (error instanceof PithyError) throw error;
|
|
55
|
-
throw new
|
|
56
|
-
message: `Cloudflare
|
|
172
|
+
if (isTimeout(error)) throw new UpstreamTimeoutError({
|
|
173
|
+
message: `Cloudflare did not answer in time: ${operation}.`,
|
|
174
|
+
action: "Re-run the command. A create that timed out may already have been applied — check before retrying it.",
|
|
57
175
|
detail: messageOf(error)
|
|
58
176
|
}, { cause: error });
|
|
177
|
+
throw cloudflareRefusal({
|
|
178
|
+
problem: `Cloudflare request failed: ${operation}.`,
|
|
179
|
+
apiErrors: cloudflareApiErrors(error),
|
|
180
|
+
status: statusOf(error),
|
|
181
|
+
...options?.permission === void 0 ? {} : { permission: options.permission },
|
|
182
|
+
detail: messageOf(error),
|
|
183
|
+
cause: error
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Compose the refusal every failed Cloudflare call renders: the problem line, then the API's own
|
|
189
|
+
* answer, then — only for an auth-class code — what to do about it.
|
|
190
|
+
*
|
|
191
|
+
* One place, so a fix reaches every provisioner at once — and **one place is a claim a test has to
|
|
192
|
+
* hold, because it was false the first time it was written.** Round one of #534 said
|
|
193
|
+
* `CloudflareBuildsManager` was the only path composing a refusal by hand; `mintToken`, the Stream
|
|
194
|
+
* direct upload and two integration helpers were composing their own, so `pithy token mint` — the
|
|
195
|
+
* bootstrap command an under-scoped token meets first — still printed the operation and nothing else
|
|
196
|
+
* on the identical 401 body. `refusalSites.test.ts` now fails any `new CloudflareRequestError(`
|
|
197
|
+
* outside this file, so "the only place" is enforced rather than remembered.
|
|
198
|
+
*
|
|
199
|
+
* CLAUDE.md §Errors decides where each string lands. Cloudflare's sentence and its docs link are the
|
|
200
|
+
* **caller's** information — what happened, in the words of whoever it happened to — so they go in
|
|
201
|
+
* `message`, field by field off `errors[]` and never as `JSON.stringify(body)`: these managers are
|
|
202
|
+
* imported into Worker paths too, so `message` can reach a browser through `clientError`. The
|
|
203
|
+
* structured `code` and link ride along in `params`, which is client-facing by definition, so a
|
|
204
|
+
* translating client can render them under the error code. "Add this grant, then re-run" names a
|
|
205
|
+
* console the operator is sitting in front of, so it is an `action`. The raw body stays `detail`.
|
|
206
|
+
*
|
|
207
|
+
* The code stays `cloudflare/request_failed` at 502: a dedicated sibling of `core/upstream_failed`,
|
|
208
|
+
* already the upstream class CLAUDE.md asks for, and pinned by `core`'s payload test.
|
|
209
|
+
*
|
|
210
|
+
* **The message is one line, and that is a kit-wide rule this had to learn the hard way.** Round one
|
|
211
|
+
* of #534 made it a small document — problem line, `errors[]` indented beneath, action last — which
|
|
212
|
+
* reads beautifully in a terminal and is unencodable everywhere else. A `PithyError` raised inside a
|
|
213
|
+
* `classifiedSteps` step crosses a durable boundary as one string whose newline separates the sentence
|
|
214
|
+
* from the remedy, so a multi-line `message` was declined whole at the reader and the operator got
|
|
215
|
+
* `core/workflow_failed` 500 about durable execution instead of the API answer this issue exists to
|
|
216
|
+
* show them. The same newline is what the CLI's action line is, so an upstream sentence containing one
|
|
217
|
+
* forges a remedy Pithy never wrote. `Cloudflare said:` marks the boundary between our words and
|
|
218
|
+
* theirs with a word instead of an indent, and a word survives a step record, a `--json` line and a
|
|
219
|
+
* browser.
|
|
220
|
+
*/
|
|
221
|
+
function cloudflareRefusal(args) {
|
|
222
|
+
const shown = args.apiErrors.slice(0, MAX_API_ERRORS);
|
|
223
|
+
const said = cloudflareSaid(args.problem, shown);
|
|
224
|
+
return new CloudflareRequestError({
|
|
225
|
+
message: said.message,
|
|
226
|
+
action: permissionAction(shown, args.status, args.permission),
|
|
227
|
+
params: said.params,
|
|
228
|
+
detail: args.detail
|
|
229
|
+
}, { cause: args.cause });
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Pithy's problem line with Cloudflare's own answer on the end of it, and the same answer as `params`.
|
|
233
|
+
*
|
|
234
|
+
* Exported because {@link cloudflareRefusal} is not the only refusal that must carry it. The 403 on
|
|
235
|
+
* `accounts/tokens create` says something a generic refusal cannot — the token got in and may not
|
|
236
|
+
* create tokens — so it keeps its own diagnosis and its own code, and *that* is what made it drop
|
|
237
|
+
* Cloudflare's answer for two rounds: a call site with a better sentence had no way to keep the API's
|
|
238
|
+
* words. Now it composes the sentence here like everything else, and the only thing it supplies is the
|
|
239
|
+
* problem line.
|
|
240
|
+
*
|
|
241
|
+
* One line, always. Whitespace runs collapse and entries join with a semicolon. The problem line is
|
|
242
|
+
* flattened along with the rest because a call site interpolates values into it (`KV get for key
|
|
243
|
+
* '<key>'`), and a key holding a newline is an operator's refusal wearing a forged action line.
|
|
244
|
+
*
|
|
245
|
+
* **What gets cut when it does not fit is the part Pithy can afford to lose.** The problem line is
|
|
246
|
+
* bounded to {@link MAX_PROBLEM_LINE} first, so a 512-byte KV key or a 1024-byte R2 one cannot crowd out
|
|
247
|
+
* the answer; then whole entries are dropped from the end until the sentence fits
|
|
248
|
+
* {@link MAX_REFUSAL_MESSAGE}, the bound of the durable step channel. Slicing the composed sentence
|
|
249
|
+
* instead — which is what this did — discards the tail, and the tail is always Cloudflare's answer.
|
|
250
|
+
*/
|
|
251
|
+
function cloudflareSaid(problem, apiErrors) {
|
|
252
|
+
const shown = apiErrors.slice(0, MAX_API_ERRORS);
|
|
253
|
+
const primary = shown.find((entry) => entry.code !== void 0) ?? shown[0];
|
|
254
|
+
const phrases = shown.map(apiErrorPhrase).filter((phrase) => phrase !== "");
|
|
255
|
+
const head = bounded(oneLine(problem), MAX_PROBLEM_LINE);
|
|
256
|
+
if (phrases.length === 0) return {
|
|
257
|
+
message: head,
|
|
258
|
+
params: apiErrorParams(primary)
|
|
259
|
+
};
|
|
260
|
+
const kept = [...phrases];
|
|
261
|
+
const compose = () => oneLine(`${head} Cloudflare said: ${kept.join("; ")}${kept.length < phrases.length ? "; …" : ""}`);
|
|
262
|
+
let composed = compose();
|
|
263
|
+
while (kept.length > 1 && composed.length > MAX_REFUSAL_MESSAGE) {
|
|
264
|
+
kept.pop();
|
|
265
|
+
composed = compose();
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
message: bounded(composed, MAX_REFUSAL_MESSAGE),
|
|
269
|
+
params: apiErrorParams(primary)
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Cloudflare's `errors[]`, read off a thrown SDK error (`APIError.errors`) or handed the array
|
|
274
|
+
* straight from an unwrapped envelope. Duck-typed rather than `instanceof APIError`, matching
|
|
275
|
+
* {@link isNotFoundError}: the SDK is one producer of this shape and the raw-`fetch` path is another.
|
|
276
|
+
*
|
|
277
|
+
* Entries are read field by field and dropped when they carry neither a code nor a sentence, so a
|
|
278
|
+
* malformed body degrades to today's refusal instead of rendering an empty line. A
|
|
279
|
+
* `documentation_url` is kept only when it is `https:` — the link is printed, and a printed link is
|
|
280
|
+
* one somebody clicks.
|
|
281
|
+
*/
|
|
282
|
+
function cloudflareApiErrors(source) {
|
|
283
|
+
const raw = Array.isArray(source) ? source : typeof source === "object" && source !== null ? source.errors : void 0;
|
|
284
|
+
if (!Array.isArray(raw)) return [];
|
|
285
|
+
return raw.flatMap(toApiError);
|
|
286
|
+
}
|
|
287
|
+
/** One raw `errors[]` entry, narrowed — or nothing, when it says nothing. */
|
|
288
|
+
function toApiError(entry) {
|
|
289
|
+
if (typeof entry !== "object" || entry === null) return [];
|
|
290
|
+
const { code, message, documentation_url: docs } = entry;
|
|
291
|
+
const parsed = {};
|
|
292
|
+
if (typeof code === "number") parsed.code = code;
|
|
293
|
+
if (typeof message === "string") {
|
|
294
|
+
const flattened = oneLine(message).slice(0, MAX_API_MESSAGE);
|
|
295
|
+
if (flattened.length > 0) parsed.message = flattened;
|
|
59
296
|
}
|
|
297
|
+
if (typeof docs === "string" && docs.startsWith("https://") && !/\s/.test(docs)) parsed.documentationUrl = docs;
|
|
298
|
+
return parsed.code === void 0 && parsed.message === void 0 ? [] : [parsed];
|
|
299
|
+
}
|
|
300
|
+
/** One entry as the operator reads it, inline: `<code> <sentence> — <link>`. */
|
|
301
|
+
function apiErrorPhrase(entry) {
|
|
302
|
+
const said = [entry.code, entry.message].filter((part) => part !== void 0).join(" ");
|
|
303
|
+
if (said === "") return "";
|
|
304
|
+
return entry.documentationUrl === void 0 ? said : `${said} — ${entry.documentationUrl}`;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* The API's answer as a translating client receives it: one sentence a locale can write into its own
|
|
308
|
+
* wording, plus the structured fields a client may render itself.
|
|
309
|
+
*
|
|
310
|
+
* **`apiAnswer` is supplied on every refusal, and that is the whole point of it.** A locale's sentence
|
|
311
|
+
* is one string with no `if` in it, and `interpolate` leaves a placeholder nobody supplied written out
|
|
312
|
+
* as `{apiAnswer}` — so a `{placeholder}` in a catalog is a claim that *every* throw of this code
|
|
313
|
+
* passes that name, degraded paths included. A call that never reached Cloudflare has no answer, so
|
|
314
|
+
* this one is `""` there, and the value carries its own leading separator (`": 10000 Authentication
|
|
315
|
+
* error"`) so the one sentence closes correctly with the answer and without it. `core`'s
|
|
316
|
+
* `GUARANTEED_ERROR_PARAMS` is where that promise is declared and where `@pithy-sh/i18n` reads it.
|
|
317
|
+
*
|
|
318
|
+
* The three structured fields stay optional, because Cloudflare's own body is: an entry may carry a
|
|
319
|
+
* code with no sentence, a sentence with no code, and usually no link at all. They are for a client
|
|
320
|
+
* reading `payload.params` directly, never for a catalog placeholder.
|
|
321
|
+
*/
|
|
322
|
+
function apiErrorParams(entry) {
|
|
323
|
+
if (entry === void 0) return { apiAnswer: "" };
|
|
324
|
+
const said = [entry.code, entry.message].filter((part) => part !== void 0).join(" ");
|
|
325
|
+
return {
|
|
326
|
+
apiAnswer: said.length === 0 ? "" : `: ${said}`,
|
|
327
|
+
...entry.code === void 0 ? {} : { apiCode: entry.code },
|
|
328
|
+
...entry.message === void 0 ? {} : { apiMessage: entry.message },
|
|
329
|
+
...entry.documentationUrl === void 0 ? {} : { apiDocumentationUrl: entry.documentationUrl }
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* The action line for an auth-class refusal, and nothing at all for any other failure — a 500 that
|
|
334
|
+
* grew an "add a permission" line would send every operator down the wrong path.
|
|
335
|
+
*
|
|
336
|
+
* **The API's code decides; the status is only consulted when there is no code.** Cloudflare returns
|
|
337
|
+
* `10000` under 400, 401 and 403 depending on the endpoint, which is why the status cannot be the
|
|
338
|
+
* discriminator — and the converse is the half that shipped wrong: a 403 is *also* how Cloudflare
|
|
339
|
+
* answers `10021 Script startup exceeded CPU limit`, and "add Account → Workers Scripts to it" changes
|
|
340
|
+
* nothing about a CPU limit. So when Cloudflare named any code at all, the codes are the whole of the
|
|
341
|
+
* evidence and the status is not consulted; only a body-less throw (`401 status code (no body)`) falls
|
|
342
|
+
* back to it.
|
|
343
|
+
*
|
|
344
|
+
* **What is known and what is concluded are kept apart, because the refusal cannot tell them apart.**
|
|
345
|
+
* Cloudflare answered `10000` for this one call; that is all it said. A token missing this product's
|
|
346
|
+
* grant, a revoked token and a token pointed at the wrong account all produce it, and this process can
|
|
347
|
+
* separate none of the three. So the line names all three, in the order an operator can check them,
|
|
348
|
+
* and asserts none. It named two for one round — "reaches other products" or "reaches none" — which
|
|
349
|
+
* excluded the third by construction: a token in the *wrong* account reaches other products in that
|
|
350
|
+
* account, so the operator was told to add a grant they already held.
|
|
351
|
+
*
|
|
352
|
+
* The **level** is deliberately absent too. The call that raised #534 was a *list* — Read would have
|
|
353
|
+
* satisfied it — so "Turnstile → Edit" would have been a guess dressed as an instruction. Name the
|
|
354
|
+
* group; the operator picks the level their calls need.
|
|
355
|
+
*/
|
|
356
|
+
function permissionAction(apiErrors, status, hint) {
|
|
357
|
+
if (!isAuthClassRefusal(apiErrors, status)) return void 0;
|
|
358
|
+
const permission = accountPermissionOf(apiErrors) ?? hint;
|
|
359
|
+
return `A missing grant, a dead token and the wrong account all look the same here. Check ${permission === void 0 ? "the token's permission for this product" : `the token for Account → ${permission}`}, then CLOUDFLARE_API_TOKEN, then CLOUDFLARE_ACCOUNT_ID.`;
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Whether the refusal is the credentials failing rather than the request — the one question the action
|
|
363
|
+
* line above is allowed to answer. Codes win where there are codes; status is the body-less fallback.
|
|
364
|
+
*/
|
|
365
|
+
function isAuthClassRefusal(apiErrors, status) {
|
|
366
|
+
const coded = apiErrors.filter((entry) => entry.code !== void 0);
|
|
367
|
+
if (coded.length > 0) return coded.some((entry) => AUTH_CLASS_CODES.has(entry.code));
|
|
368
|
+
return status === 401 || status === 403;
|
|
369
|
+
}
|
|
370
|
+
/** The account permission group behind the refused endpoint, per Cloudflare's own docs link. */
|
|
371
|
+
function accountPermissionOf(apiErrors) {
|
|
372
|
+
for (const entry of apiErrors) {
|
|
373
|
+
if (entry.documentationUrl === void 0) continue;
|
|
374
|
+
const segments = entry.documentationUrl.split("/");
|
|
375
|
+
const resource = segments[segments.indexOf("resources") + 1];
|
|
376
|
+
if (resource === void 0 || !Object.hasOwn(ACCOUNT_PERMISSIONS, resource)) continue;
|
|
377
|
+
return ACCOUNT_PERMISSIONS[resource];
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* The names a timed-out call throws under, **measured off the two producers rather than guessed**.
|
|
382
|
+
*
|
|
383
|
+
* Round two read `error.name` and `error.code`, which is what an SDK error and an undici error look
|
|
384
|
+
* like in a fixture somebody types out. Driven against the real thing, neither producer sets what was
|
|
385
|
+
* being read, and the 504 branch could not fire for either:
|
|
386
|
+
*
|
|
387
|
+
* - **The SDK never assigns `name`.** `APIConnectionTimeoutError extends APIConnectionError extends
|
|
388
|
+
* APIError extends CloudflareError extends Error`, and not one of them touches `this.name` — so
|
|
389
|
+
* `error.name` is `"Error"` and `error.code` is `undefined`. Driven with a real `Cloudflare` client
|
|
390
|
+
* (`maxRetries: 0`, `timeout: 150`) against a `node:net` server that accepts and never answers:
|
|
391
|
+
* `ctor=APIConnectionTimeoutError name="Error" code=undefined status=undefined msg="Request timed
|
|
392
|
+
* out."`. The class name is the only marker it carries, so the constructor is read as well as `name`.
|
|
393
|
+
* - **`fetch` puts the timeout on `cause`, not on the throw.** A connect timeout rejects with
|
|
394
|
+
* `TypeError: fetch failed` — `name="TypeError"`, `code=undefined` — and undici's own
|
|
395
|
+
* `ConnectTimeoutError` (`code="UND_ERR_CONNECT_TIMEOUT"`) is one level down. Driven against
|
|
396
|
+
* TEST-NET-1 (`http://192.0.2.1`, 10.5s, undici's default connect timeout). So the chain is walked;
|
|
397
|
+
* reading only the top level made `TIMEOUT_CODES` dead for the raw-`fetch` escape hatch in
|
|
398
|
+
* `CloudflareBuildsManager` that it was written for.
|
|
399
|
+
*
|
|
400
|
+
* Both are pinned by `timeoutProducers.test.ts`, which builds its fixtures from those producers rather
|
|
401
|
+
* than from this list — a fixture written by hand agrees with whoever wrote it.
|
|
402
|
+
*
|
|
403
|
+
* Duck-typed rather than `instanceof APIConnectionTimeoutError`, matching every other predicate here:
|
|
404
|
+
* the SDK is one producer and `fetch` is another, and an `instanceof` across two copies of the SDK
|
|
405
|
+
* answers false. `AbortSignal.timeout` is the third — a `DOMException` whose `name` really is
|
|
406
|
+
* `"TimeoutError"` — and it is what a caller-imposed deadline raises.
|
|
407
|
+
*/
|
|
408
|
+
const TIMEOUT_NAMES = /* @__PURE__ */ new Set([
|
|
409
|
+
"APIConnectionTimeoutError",
|
|
410
|
+
"TimeoutError",
|
|
411
|
+
"ConnectTimeoutError",
|
|
412
|
+
"HeadersTimeoutError",
|
|
413
|
+
"BodyTimeoutError"
|
|
414
|
+
]);
|
|
415
|
+
const TIMEOUT_CODES = /* @__PURE__ */ new Set([
|
|
416
|
+
"ETIMEDOUT",
|
|
417
|
+
"ESOCKETTIMEDOUT",
|
|
418
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
419
|
+
"UND_ERR_HEADERS_TIMEOUT",
|
|
420
|
+
"UND_ERR_BODY_TIMEOUT"
|
|
421
|
+
]);
|
|
422
|
+
/** How far down a `cause` chain the reason is looked for. Bounded, so a cyclic chain cannot spin. */
|
|
423
|
+
const MAX_CAUSE_DEPTH = 5;
|
|
424
|
+
/**
|
|
425
|
+
* Whether a throw is "nobody answered in time" rather than "Cloudflare answered and refused".
|
|
426
|
+
*
|
|
427
|
+
* **A throw carrying a status answered**, whatever is underneath it: an SDK `APIError` is Cloudflare's
|
|
428
|
+
* own reply, so its cause chain is not consulted and the only statuses that mean time ran out are the
|
|
429
|
+
* three below. `408` and `504` are here because a proxy in front of the API answers them for exactly
|
|
430
|
+
* this event; `524` is Cloudflare's own "a timeout occurred", and it can reach a manager that fronts a
|
|
431
|
+
* Worker. Every other throw is a transport failure, and the reason for one lives somewhere in its
|
|
432
|
+
* `cause` chain — `TypeError: fetch failed` → `ConnectTimeoutError`, or the SDK's `APIConnectionError`
|
|
433
|
+
* → `TypeError` → the socket error. A refused connection walks the same chain and states `ECONNREFUSED`
|
|
434
|
+
* at the bottom of it, which is not in either set, so it stays the 502 it should be.
|
|
435
|
+
*/
|
|
436
|
+
function isTimeout(error) {
|
|
437
|
+
const answered = statusOf(error);
|
|
438
|
+
if (answered !== void 0) return answered === 408 || answered === 504 || answered === 524;
|
|
439
|
+
let current = error;
|
|
440
|
+
for (let depth = 0; depth < MAX_CAUSE_DEPTH && typeof current === "object" && current !== null; depth += 1) {
|
|
441
|
+
if (statesTimeout(current)) return true;
|
|
442
|
+
current = current.cause;
|
|
443
|
+
}
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
/** Whether one link of a `cause` chain says, in any of the three ways a producer says it, "out of time". */
|
|
447
|
+
function statesTimeout(error) {
|
|
448
|
+
const { name, code, constructor: ctor } = error;
|
|
449
|
+
if (typeof name === "string" && TIMEOUT_NAMES.has(name)) return true;
|
|
450
|
+
if (typeof code === "string" && TIMEOUT_CODES.has(code)) return true;
|
|
451
|
+
const className = ctor?.name;
|
|
452
|
+
return typeof className === "string" && TIMEOUT_NAMES.has(className);
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* The HTTP status of a thrown SDK error, when it carries one. Exported because a call site that
|
|
456
|
+
* composes its own refusal through {@link cloudflareRefusal} has to hand it the same status this
|
|
457
|
+
* module would have read — a hand-rolled `(error as { status?: number }).status` beside it is the
|
|
458
|
+
* duplicate that drifts.
|
|
459
|
+
*/
|
|
460
|
+
function statusOf(error) {
|
|
461
|
+
if (typeof error !== "object" || error === null) return void 0;
|
|
462
|
+
const status = error.status;
|
|
463
|
+
return typeof status === "number" ? status : void 0;
|
|
60
464
|
}
|
|
61
465
|
/** The raw message of an unknown throw, for use as a PithyError `detail`. One source of truth. */
|
|
62
466
|
function messageOf(error) {
|
|
@@ -79,6 +483,11 @@ function isNotFoundError(error) {
|
|
|
79
483
|
* Whether a thrown SDK error is an HTTP 403 — the token reached the API but lacks the permission for
|
|
80
484
|
* the operation. The signal a caller uses to turn a raw "Unauthorized" into an actionable
|
|
81
485
|
* "grant this permission group" message instead of a generic request failure.
|
|
486
|
+
*
|
|
487
|
+
* Strictly 403, and it stays that way: its callers branch on it to *swallow* a denial
|
|
488
|
+
* (`getTokenName` answers `null`), so widening it to 401 would report a dead token as a permission
|
|
489
|
+
* this project does not need. The broader "did the credentials get in" question is
|
|
490
|
+
* {@link AUTH_CLASS_CODES}, which reads the API's own code rather than the status.
|
|
82
491
|
*/
|
|
83
492
|
function isAuthorizationError(error) {
|
|
84
493
|
return typeof error === "object" && error !== null && error.status === 403;
|
|
@@ -97,4 +506,4 @@ function decodeResponse(schema, raw, context) {
|
|
|
97
506
|
return parsed.data;
|
|
98
507
|
}
|
|
99
508
|
//#endregion
|
|
100
|
-
export { CloudflareInvalidResponseError, CloudflareNotConfiguredError, CloudflareRequestError, cloudflareRequest, decodeResponse, isAuthorizationError, isNotFoundError, messageOf, reasonOf };
|
|
509
|
+
export { CloudflareInvalidResponseError, CloudflareNotConfiguredError, CloudflareRequestError, cloudflareApiErrors, cloudflareRefusal, cloudflareRequest, cloudflareSaid, decodeResponse, isAuthorizationError, isNotFoundError, messageOf, reasonOf, statusOf };
|