mandala-computer-mcp 0.1.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/LICENSE +21 -0
- package/README.md +544 -0
- package/dist/api.d.ts +186 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +932 -0
- package/dist/api.js.map +1 -0
- package/dist/cli.d.ts +55 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +292 -0
- package/dist/cli.js.map +1 -0
- package/dist/errors.d.ts +560 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +873 -0
- package/dist/errors.js.map +1 -0
- package/dist/events.d.ts +406 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +1679 -0
- package/dist/events.js.map +1 -0
- package/dist/format.d.ts +125 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/format.js +180 -0
- package/dist/format.js.map +1 -0
- package/dist/http.d.ts +46 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +792 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/paths.d.ts +394 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +677 -0
- package/dist/paths.js.map +1 -0
- package/dist/server.d.ts +18 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +97 -0
- package/dist/server.js.map +1 -0
- package/dist/session.d.ts +78 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +166 -0
- package/dist/session.js.map +1 -0
- package/dist/stdio.d.ts +11 -0
- package/dist/stdio.d.ts.map +1 -0
- package/dist/stdio.js +43 -0
- package/dist/stdio.js.map +1 -0
- package/dist/tools/agent.d.ts +16 -0
- package/dist/tools/agent.d.ts.map +1 -0
- package/dist/tools/agent.js +147 -0
- package/dist/tools/agent.js.map +1 -0
- package/dist/tools/computers.d.ts +3 -0
- package/dist/tools/computers.d.ts.map +1 -0
- package/dist/tools/computers.js +1037 -0
- package/dist/tools/computers.js.map +1 -0
- package/dist/tools/events.d.ts +3 -0
- package/dist/tools/events.d.ts.map +1 -0
- package/dist/tools/events.js +1077 -0
- package/dist/tools/events.js.map +1 -0
- package/dist/tools/guest.d.ts +3 -0
- package/dist/tools/guest.d.ts.map +1 -0
- package/dist/tools/guest.js +761 -0
- package/dist/tools/guest.js.map +1 -0
- package/dist/tools/input.d.ts +3 -0
- package/dist/tools/input.d.ts.map +1 -0
- package/dist/tools/input.js +240 -0
- package/dist/tools/input.js.map +1 -0
- package/dist/tools/snapshots.d.ts +3 -0
- package/dist/tools/snapshots.d.ts.map +1 -0
- package/dist/tools/snapshots.js +333 -0
- package/dist/tools/snapshots.js.map +1 -0
- package/dist/tools/templates.d.ts +3 -0
- package/dist/tools/templates.d.ts.map +1 -0
- package/dist/tools/templates.js +492 -0
- package/dist/tools/templates.js.map +1 -0
- package/dist/tools/types.d.ts +18 -0
- package/dist/tools/types.d.ts.map +1 -0
- package/dist/tools/types.js +2 -0
- package/dist/tools/types.js.map +1 -0
- package/dist/tools/webhooks.d.ts +3 -0
- package/dist/tools/webhooks.d.ts.map +1 -0
- package/dist/tools/webhooks.js +260 -0
- package/dist/tools/webhooks.js.map +1 -0
- package/package.json +59 -0
package/dist/errors.js
ADDED
|
@@ -0,0 +1,873 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the platform's status codes mean, as types.
|
|
3
|
+
*
|
|
4
|
+
* The distinctions here are the ones an agent has to act on and cannot infer
|
|
5
|
+
* from prose. A 400 never clears and retrying it burns a turn. A 402 is a plan
|
|
6
|
+
* limit, which no amount of waiting fixes and which the user — not the model —
|
|
7
|
+
* has to resolve. A 409 is the one that is not uniform: most of them are a
|
|
8
|
+
* passing state and worth retrying, and some are a decision about the request
|
|
9
|
+
* that no retry turns into a yes — see {@link ConflictError} and
|
|
10
|
+
* {@link MoveRequiredError}.
|
|
11
|
+
*
|
|
12
|
+
* Mirrors the mapping in mandala-computer-python's `_exceptions.py`, and
|
|
13
|
+
* deliberately so: two clients disagreeing about what a 402 is means the same
|
|
14
|
+
* failure reads differently depending on which one you reached for.
|
|
15
|
+
*/
|
|
16
|
+
export class MandalaError extends Error {
|
|
17
|
+
name = 'MandalaError';
|
|
18
|
+
}
|
|
19
|
+
export class APIError extends MandalaError {
|
|
20
|
+
status;
|
|
21
|
+
body;
|
|
22
|
+
name = 'APIError';
|
|
23
|
+
/**
|
|
24
|
+
* The platform's own word for what KIND of refusal this is, where it sent
|
|
25
|
+
* one: `contention`, `starting`, `unavailable` or `unsupported` (OPL-3898).
|
|
26
|
+
* `undefined` for most errors, and always will be — the platform is explicit
|
|
27
|
+
* that an absent value means unclassified rather than "none of the four".
|
|
28
|
+
*
|
|
29
|
+
* Read on the base class rather than on the one 409 it was filed for, because
|
|
30
|
+
* the platform keys it on the ERROR and not on the route: the same sentinel is
|
|
31
|
+
* reached from several endpoints, and `unavailable` arrives as a 400 as well
|
|
32
|
+
* as a 409 — whoever loses the race to the running check hears the same fact
|
|
33
|
+
* the caller who arrived a moment earlier heard.
|
|
34
|
+
*/
|
|
35
|
+
reason;
|
|
36
|
+
constructor(message, status, body) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.status = status;
|
|
39
|
+
this.body = body;
|
|
40
|
+
this.reason = refusalReason(body);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The two answers `reason` can carry, as sets rather than as types.
|
|
45
|
+
*
|
|
46
|
+
* Kept as data deliberately. The platform states that a fifth word may be added
|
|
47
|
+
* and that a client must read one it does not recognise as "no answer given" —
|
|
48
|
+
* an allow-list of classes would make the next word a breaking change, and the
|
|
49
|
+
* same word arrives on more than one status, so a subclass of any one of them
|
|
50
|
+
* could not carry it. Both memberships are tested rather than one being inferred
|
|
51
|
+
* from the other, which is what makes an unknown word fall through to the type
|
|
52
|
+
* answer instead of reading as permanent. Mirrors `_REASON_CLEARS` and
|
|
53
|
+
* `_REASON_PERMANENT` in mandala-computer-python's `_exceptions.py`.
|
|
54
|
+
*/
|
|
55
|
+
const REASON_CLEARS = new Set(['contention', 'starting']);
|
|
56
|
+
const REASON_PERMANENT = new Set(['unavailable', 'unsupported']);
|
|
57
|
+
/**
|
|
58
|
+
* What to tell a model about a refusal the platform classified, or `undefined`.
|
|
59
|
+
*
|
|
60
|
+
* The word itself is for a program, and the client here is a language model that
|
|
61
|
+
* cannot switch on a JSON key it never sees: {@link failed} renders an error as
|
|
62
|
+
* one sentence. So the classification travels as the clause it means, and the
|
|
63
|
+
* loop OPL-3898 was filed about — a blanket retry against a computer that is
|
|
64
|
+
* simply stopped — is the one these sentences exist to stop.
|
|
65
|
+
*
|
|
66
|
+
* Deliberately silent about anything else. A word this version does not know is
|
|
67
|
+
* not described at all, because inventing advice for it is the mistake the
|
|
68
|
+
* platform's "absent means unclassified" contract exists to prevent.
|
|
69
|
+
*/
|
|
70
|
+
export function reasonAdvice(reason) {
|
|
71
|
+
switch (reason) {
|
|
72
|
+
case 'contention':
|
|
73
|
+
return 'something was in flight; the same call works once it finishes, so this one is worth sending again';
|
|
74
|
+
case 'starting':
|
|
75
|
+
return 'the guest agent is still inside its boot window, so this is worth sending again in a moment';
|
|
76
|
+
case 'unavailable':
|
|
77
|
+
return 'the computer is not running, and this does NOT clear by waiting — start_computer is the fix, and retrying without it spends a turn every time';
|
|
78
|
+
case 'unsupported':
|
|
79
|
+
return 'this computer cannot do it at all, so do not retry it — the answer is the same forever';
|
|
80
|
+
default:
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The platform's one-word classification off a refusal body, or `undefined`.
|
|
86
|
+
*
|
|
87
|
+
* Shape-checked in the manner of {@link moveOffer} and for its reason: this
|
|
88
|
+
* decides a retry policy, so a body with a `reason` that is not a string has to
|
|
89
|
+
* read as "no answer given" and fall back to what this server did before the key
|
|
90
|
+
* existed. Any string is kept, including one this version has never heard of —
|
|
91
|
+
* the callers compare against the sets above, because the sets are the contract
|
|
92
|
+
* and the raw word belongs to whoever is embedding this.
|
|
93
|
+
*/
|
|
94
|
+
function refusalReason(body) {
|
|
95
|
+
if (!body || typeof body !== 'object')
|
|
96
|
+
return undefined;
|
|
97
|
+
const reason = body.reason;
|
|
98
|
+
return typeof reason === 'string' ? reason : undefined;
|
|
99
|
+
}
|
|
100
|
+
/** 401 — the key is missing, malformed, or revoked. */
|
|
101
|
+
export class AuthenticationError extends APIError {
|
|
102
|
+
name = 'AuthenticationError';
|
|
103
|
+
}
|
|
104
|
+
/** 402 — the account's plan will not allow this. Not a retry. */
|
|
105
|
+
export class PlanLimitError extends APIError {
|
|
106
|
+
name = 'PlanLimitError';
|
|
107
|
+
}
|
|
108
|
+
/** 403 — the key's role on the account is too low for this route. */
|
|
109
|
+
export class PermissionDeniedError extends APIError {
|
|
110
|
+
name = 'PermissionDeniedError';
|
|
111
|
+
}
|
|
112
|
+
/** 404 — no such computer, snapshot, or route. */
|
|
113
|
+
export class NotFoundError extends APIError {
|
|
114
|
+
name = 'NotFoundError';
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* 409 — the thing exists but is in the wrong state for this right now.
|
|
118
|
+
*
|
|
119
|
+
* The one status on this list that is usually temporary: a guest still booting,
|
|
120
|
+
* a guest agent busy with another call, a desktop session that has not come up
|
|
121
|
+
* yet. Worth retrying; the others on this list are not.
|
|
122
|
+
*
|
|
123
|
+
* USUALLY, and the exception is why {@link MoveRequiredError} exists. Whether a
|
|
124
|
+
* 409 clears is a property of the BODY and not of the status: some describe a
|
|
125
|
+
* passing state, and some describe a decision about the request — the size does
|
|
126
|
+
* not fit, the computer is the wrong one for this, the saved session cannot
|
|
127
|
+
* travel — which no amount of retrying turns into a yes. This class said
|
|
128
|
+
* "worth retrying" flatly, {@link isTransient} agreed with it, and that
|
|
129
|
+
* predicate is exported, so a host application wrapping a resize in
|
|
130
|
+
* `if (isTransient(err)) retry()` looped on a refusal that was never going to
|
|
131
|
+
* move (OPL-3775).
|
|
132
|
+
*
|
|
133
|
+
* Only the refusal that could be acted on has been given a class of its own so
|
|
134
|
+
* far, because a type is worth adding where a caller can DO something different
|
|
135
|
+
* with it. The rest stay here and are told apart by {@link APIError.reason},
|
|
136
|
+
* which is the word the platform added for that purpose (OPL-3898) and the one
|
|
137
|
+
* its own reference now says to switch on — never the sentence, which is prose
|
|
138
|
+
* written for a person and rewritten whenever a better one is. Where no word was
|
|
139
|
+
* sent this class means what it always did, and that fallback is the contract
|
|
140
|
+
* rather than a gap: not every refusal here has an answer yet.
|
|
141
|
+
*/
|
|
142
|
+
export class ConflictError extends APIError {
|
|
143
|
+
name = 'ConflictError';
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* The 409 that is an OFFER rather than a refusal: a resize needs the computer
|
|
147
|
+
* moved to another host first.
|
|
148
|
+
*
|
|
149
|
+
* `PATCH /computers/{id}` growing `ram_mb` past what the computer's current host
|
|
150
|
+
* can run answers 409 with a `move` object on the body rather than only a
|
|
151
|
+
* sentence — `{"required":true,"possible":true}` means somewhere else in the
|
|
152
|
+
* region could run that size, and `POST /computers/{id}/move` is how a caller
|
|
153
|
+
* agrees to go there. `possible:false` means nowhere in the region can, and the
|
|
154
|
+
* size is the thing to change.
|
|
155
|
+
*
|
|
156
|
+
* Its own class for the reason {@link RangeNotSatisfiableError} has one: it is a
|
|
157
|
+
* refusal the caller can correct without knowing anything it did not just learn,
|
|
158
|
+
* and the correction is a different call rather than a smaller number. Reaching
|
|
159
|
+
* it through a bare `ConflictError` left the flag on `body` where nothing looked
|
|
160
|
+
* for it, and left the retry predicate saying yes.
|
|
161
|
+
*
|
|
162
|
+
* {@link movePossible} is the branch, and it is deliberately read off the body
|
|
163
|
+
* here rather than left to every caller: `move.required` is true in both cases
|
|
164
|
+
* and it is the second field that decides what to do.
|
|
165
|
+
*/
|
|
166
|
+
export class MoveRequiredError extends ConflictError {
|
|
167
|
+
movePossible;
|
|
168
|
+
name = 'MoveRequiredError';
|
|
169
|
+
constructor(message, status, body,
|
|
170
|
+
/** Whether a host in this region could run the size that was asked for. */
|
|
171
|
+
movePossible) {
|
|
172
|
+
super(message, status, body);
|
|
173
|
+
this.movePossible = movePossible;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* The `move` object the platform puts on the refusal above, if this body has one.
|
|
178
|
+
*
|
|
179
|
+
* Shape-checked rather than trusted: this decides a retry policy and a tool's
|
|
180
|
+
* next step, so a body with a `move` key that is a string, or an object with no
|
|
181
|
+
* `possible`, must read as "not that refusal" rather than as a move that is
|
|
182
|
+
* impossible. Absent and malformed are the same answer here, and it is the
|
|
183
|
+
* conservative one — an ordinary ConflictError, which is what this was before.
|
|
184
|
+
*/
|
|
185
|
+
function moveOffer(body) {
|
|
186
|
+
if (!body || typeof body !== 'object')
|
|
187
|
+
return undefined;
|
|
188
|
+
const move = body.move;
|
|
189
|
+
if (!move || typeof move !== 'object')
|
|
190
|
+
return undefined;
|
|
191
|
+
const { required, possible } = move;
|
|
192
|
+
if (required !== true || typeof possible !== 'boolean')
|
|
193
|
+
return undefined;
|
|
194
|
+
return { possible };
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* 416 — the `Range` named no byte the file has.
|
|
198
|
+
*
|
|
199
|
+
* Its own class because it is the one refusal on the download route that a
|
|
200
|
+
* caller can correct without knowing anything it did not just learn: the
|
|
201
|
+
* response carries `Content-Range: bytes *\/<size>`, so the file's real length
|
|
202
|
+
* arrives with the complaint about the offset. {@link size} is that number,
|
|
203
|
+
* kept off the message so a tool can put the offset it sent beside it.
|
|
204
|
+
*
|
|
205
|
+
* A model paging a file it has not measured is the caller that meets this, and
|
|
206
|
+
* an offset past the end is the mistake it will actually make.
|
|
207
|
+
*/
|
|
208
|
+
export class RangeNotSatisfiableError extends APIError {
|
|
209
|
+
size;
|
|
210
|
+
name = 'RangeNotSatisfiableError';
|
|
211
|
+
constructor(message, status, body,
|
|
212
|
+
/** The file's real length, off `Content-Range`, when the response sent one. */
|
|
213
|
+
size) {
|
|
214
|
+
super(message, status, body);
|
|
215
|
+
this.size = size;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* 429 — the request is valid; the caller has spent a temporary rate budget.
|
|
220
|
+
*
|
|
221
|
+
* A class rather than a number in a list, and that is the OPL-3724 change in
|
|
222
|
+
* one line: {@link isTransient} used to reach 429 by matching `err.status`,
|
|
223
|
+
* which meant this client answered "is it worth retrying" by a mechanism the
|
|
224
|
+
* other two did not share. It is a moment rather than a property of the
|
|
225
|
+
* request, exactly like a 409, and it belongs in the same shape.
|
|
226
|
+
*
|
|
227
|
+
* {@link retryAfterMs} is set when the response carried a usable `Retry-After`.
|
|
228
|
+
* The poll loops honour it, because retrying at their own faster cadence is
|
|
229
|
+
* how a rate limit becomes a longer one.
|
|
230
|
+
*/
|
|
231
|
+
export class RateLimitError extends APIError {
|
|
232
|
+
retryAfterMs;
|
|
233
|
+
name = 'RateLimitError';
|
|
234
|
+
constructor(message, status, body,
|
|
235
|
+
/** From `Retry-After`, in milliseconds from now, when the response sent one. */
|
|
236
|
+
retryAfterMs) {
|
|
237
|
+
super(message, status, body);
|
|
238
|
+
this.retryAfterMs = retryAfterMs;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
/** 503 — a hypervisor could not be reached, so an inventory would be short. */
|
|
242
|
+
export class UnavailableError extends APIError {
|
|
243
|
+
name = 'UnavailableError';
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* The request was given up on before the platform answered.
|
|
247
|
+
*
|
|
248
|
+
* Not an API failure and not a connectivity failure, which is the whole reason
|
|
249
|
+
* it has a type: an aborted fetch surfaces from the client as a bare
|
|
250
|
+
* `TypeError: This operation was aborted`, and wrapping that as "could not
|
|
251
|
+
* reach <host>" told every reader the platform was down when in fact the caller
|
|
252
|
+
* had hung up. A cancellation is also the one failure here where the request
|
|
253
|
+
* may well have been received and acted on, so the message says so rather than
|
|
254
|
+
* claiming nothing happened.
|
|
255
|
+
*
|
|
256
|
+
* Deliberately not transient: retrying something nobody is waiting for is the
|
|
257
|
+
* behaviour `with(signal)` exists to stop.
|
|
258
|
+
*/
|
|
259
|
+
export class CancelledError extends MandalaError {
|
|
260
|
+
name = 'CancelledError';
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* The request never left. Nothing was dispatched, so anything may be replayed.
|
|
264
|
+
*
|
|
265
|
+
* NARROWER than it used to be, and the narrowing is the point. This class once
|
|
266
|
+
* wrapped every rejection the transport produced, which meant it also carried
|
|
267
|
+
* the failures that happen AFTER the request reached the platform — a socket
|
|
268
|
+
* reset while the response body was being read, a protocol error on the way
|
|
269
|
+
* back. Those wear the opposite outcome: the platform may well have acted, and
|
|
270
|
+
* the answer is what was lost. They now get {@link ConnectivityInterruptedError},
|
|
271
|
+
* which is a subclass, so `catch (e) { if (e instanceof ConnectivityError) }`
|
|
272
|
+
* still sees both.
|
|
273
|
+
*
|
|
274
|
+
* What is left here is what the name always claimed: DNS that did not resolve,
|
|
275
|
+
* a socket that was refused, a connect that timed out, a TLS handshake that
|
|
276
|
+
* failed. Not one byte of the request was written, so {@link isTransient} can
|
|
277
|
+
* say yes to it even for a caller replaying a create.
|
|
278
|
+
*
|
|
279
|
+
* `Api` raises this one only for a cause it can positively identify as
|
|
280
|
+
* connect-phase; see `neverDispatched` in `src/api.ts`. Everything it cannot
|
|
281
|
+
* identify is the subclass, because the cost of the two wrong answers is not
|
|
282
|
+
* symmetric — see there.
|
|
283
|
+
*/
|
|
284
|
+
export class ConnectivityError extends MandalaError {
|
|
285
|
+
name = 'ConnectivityError';
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* The request was dispatched and the answer was lost. Outcome unknown.
|
|
289
|
+
*
|
|
290
|
+
* A socket that resets while the response body is being read, an HTTP parser
|
|
291
|
+
* error on the way back, an undici body or headers timeout, a connection
|
|
292
|
+
* failure this client cannot place in either phase. The shared property is the
|
|
293
|
+
* one that matters: the platform may have received the request and acted on it,
|
|
294
|
+
* and nothing in the error says whether it did.
|
|
295
|
+
*
|
|
296
|
+
* So this is FATAL to {@link isTransient} and transparent to
|
|
297
|
+
* {@link isTransientForPoll}, and the split is the same one OPL-3724 made for
|
|
298
|
+
* 502 and 504. Its words apply here unchanged — "a status that is not safe for
|
|
299
|
+
* the riskiest caller of an exported predicate does not belong in it" — and
|
|
300
|
+
* this case had escaped them only because it wears a class whose name says the
|
|
301
|
+
* request never left. `computers.create()` reaches the platform, the platform
|
|
302
|
+
* builds the computer, the socket dies mid-response: an embedder asking
|
|
303
|
+
* {@link isTransient} used to be told yes, replayed the create, and paid for
|
|
304
|
+
* two computers.
|
|
305
|
+
*
|
|
306
|
+
* A SUBCLASS rather than a sibling, which is what keeps this from breaking
|
|
307
|
+
* anyone. `instanceof ConnectivityError` still matches, so existing catch
|
|
308
|
+
* blocks and {@link isTransientForPoll}'s floor need no change; only the one
|
|
309
|
+
* predicate that promises blind replay had to learn the difference. It is the
|
|
310
|
+
* same shape {@link MoveRequiredError} has under {@link ConflictError}, for the
|
|
311
|
+
* same reason: a case that is genuinely a kind of its parent and genuinely
|
|
312
|
+
* answers one question the other way.
|
|
313
|
+
*
|
|
314
|
+
* The poll predicate still rides it out, and that is not an oversight. The wait
|
|
315
|
+
* tools replay reads — a `GET /computers/:id`, an `exec 'exit 0'` probe — and a
|
|
316
|
+
* read whose outcome was lost can simply be read again. Only a caller who might
|
|
317
|
+
* be replaying a WRITE needs the distinction, which is exactly the caller
|
|
318
|
+
* {@link isTransient} is exported for.
|
|
319
|
+
*/
|
|
320
|
+
export class ConnectivityInterruptedError extends ConnectivityError {
|
|
321
|
+
name = 'ConnectivityInterruptedError';
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* 504, 524 — a proxy in front of the platform gave up before the platform answered.
|
|
325
|
+
*
|
|
326
|
+
* Not a refusal. The request arrived, is very likely still running, and nothing
|
|
327
|
+
* was cancelled; what ended was one hop's willingness to hold a connection open
|
|
328
|
+
* with no response crossing it.
|
|
329
|
+
*
|
|
330
|
+
* One class, two retry answers, and {@link isTransientForPoll} keeps them apart
|
|
331
|
+
* by status rather than by type on purpose — the ONE place a status number
|
|
332
|
+
* still decides anything, because a type cannot separate two statuses that
|
|
333
|
+
* share it. A 504 is worth polling again and a 524 is not, because of where
|
|
334
|
+
* each is reachable from: the wait tools are the only thing here that retries,
|
|
335
|
+
* they poll with short requests, and a 504 on one of those is infrastructure
|
|
336
|
+
* noise that clears. A 524 is only ever reached by holding a request open past
|
|
337
|
+
* the ceiling below — so retrying it unchanged reproduces it exactly, at the
|
|
338
|
+
* same place, because the hop that gave up never saw how long the caller asked
|
|
339
|
+
* to wait.
|
|
340
|
+
*
|
|
341
|
+
* Neither is in {@link isTransient}. That predicate is what an embedder may
|
|
342
|
+
* wrap a create in, and a gateway timeout is the case where the platform has
|
|
343
|
+
* most likely acted already (OPL-3724).
|
|
344
|
+
*
|
|
345
|
+
* Against `app.mandala.computer` that hop is Cloudflare and the ceiling is about
|
|
346
|
+
* two minutes. Measured 2026-08-20: `sleep 130` died at 125.2s with
|
|
347
|
+
* `timeout_s: 300` and at 125.3s with `timeout_s: 3600`, while `sleep 110`
|
|
348
|
+
* returned normally at 110.6s. A foreground `exec` slower than that always ends
|
|
349
|
+
* here; `background: true` is the shape that does not, because it answers as
|
|
350
|
+
* soon as the command has started.
|
|
351
|
+
*
|
|
352
|
+
* The abandoned command keeps running, which is why the next call on the same
|
|
353
|
+
* computer often raises {@link ConflictError} — the guest agent is still busy
|
|
354
|
+
* with it. That is this failure continuing, not a second one.
|
|
355
|
+
*/
|
|
356
|
+
export class GatewayTimeoutError extends APIError {
|
|
357
|
+
name = 'GatewayTimeoutError';
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* 521-523 — a proxy in front of the platform could not reach it.
|
|
361
|
+
*
|
|
362
|
+
* The range this once claimed — 520-523, 525, 526 — is the range before the two
|
|
363
|
+
* statuses that needed their own answers were split out of it: 520 to
|
|
364
|
+
* {@link OriginResponseError}, because the platform WAS reached, and 525-526 to
|
|
365
|
+
* {@link OriginTLSError}, because waiting does not fix a certificate.
|
|
366
|
+
*
|
|
367
|
+
* The rest of what an edge generates on its own, and the same bug as
|
|
368
|
+
* {@link GatewayTimeoutError} a few statuses along: with no class and no written
|
|
369
|
+
* message these fell through to the bare `HTTP 522`, which names no cause, no
|
|
370
|
+
* culprit and no way out — the exact reading that cost the debugging above.
|
|
371
|
+
*
|
|
372
|
+
* A different event from a gateway timeout, which is why it is a different type
|
|
373
|
+
* rather than more entries on that one. A 524 means the request arrived and is
|
|
374
|
+
* still being worked on; these mean it never arrived at all, so nothing was
|
|
375
|
+
* started and there is no command outliving anything. A caller branching on the
|
|
376
|
+
* class to decide whether its work survived gets opposite answers, correctly.
|
|
377
|
+
*
|
|
378
|
+
* Deliberately absent from mandala-computer-python's `_exceptions.py`, which
|
|
379
|
+
* this file otherwise mirrors. That mapping is of the platform's own statuses;
|
|
380
|
+
* these belong to whatever is deployed in front of it. The divergence is worth
|
|
381
|
+
* it here because this client's messages are read by a model, which cannot go
|
|
382
|
+
* and look up what a 523 is.
|
|
383
|
+
*/
|
|
384
|
+
export class OriginUnreachableError extends APIError {
|
|
385
|
+
name = 'OriginUnreachableError';
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* 502, 520 — a proxy had no usable answer from the platform.
|
|
389
|
+
*
|
|
390
|
+
* Sits between the other two and must not be filed with either, because the
|
|
391
|
+
* question a caller is really asking is whether their work happened, and these
|
|
392
|
+
* are the statuses whose honest answer is "unknown".
|
|
393
|
+
*
|
|
394
|
+
* One class, two messages, because the two do not know the same amount. A 520 is
|
|
395
|
+
* Cloudflare naming its origin's reply unreadable, so arrival is established. A
|
|
396
|
+
* 502 is any proxy saying it has nothing it can use, which covers both an
|
|
397
|
+
* invalid reply and no reply at all — indistinguishable from here, so it claims
|
|
398
|
+
* neither. See BAD_GATEWAY_MESSAGE.
|
|
399
|
+
*
|
|
400
|
+
* A 524 means the request arrived and is still being worked on. 521-523 mean it
|
|
401
|
+
* never arrived, so nothing was started. A 520 means it **did** arrive — the
|
|
402
|
+
* platform received it and then returned an empty, unknown or oversized
|
|
403
|
+
* response, so it may have been carried out in full, in part, or not at all, and
|
|
404
|
+
* the answer was lost rather than never produced.
|
|
405
|
+
*
|
|
406
|
+
* Which makes a blind retry the thing to be careful about, and a model the
|
|
407
|
+
* likeliest caller to attempt one. Re-sending a read costs nothing; re-sending a
|
|
408
|
+
* create can leave two computers where one was meant, both billable, on the
|
|
409
|
+
* strength of a failure that said the first never happened.
|
|
410
|
+
*
|
|
411
|
+
* It was filed with {@link OriginUnreachableError} at first, on the reading that
|
|
412
|
+
* the whole 52x range is the edge failing to reach the platform. It is not, and
|
|
413
|
+
* the message that came with it — "the request never arrived, so nothing was
|
|
414
|
+
* started" — was exactly the confident falsehood this work exists to remove,
|
|
415
|
+
* pointed the other way. Caught by a review of the Python SDK, which had
|
|
416
|
+
* inherited the same grouping from this file.
|
|
417
|
+
*/
|
|
418
|
+
export class OriginResponseError extends APIError {
|
|
419
|
+
name = 'OriginResponseError';
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* 525, 526 — a proxy and the platform could not agree on TLS.
|
|
423
|
+
*
|
|
424
|
+
* Split from {@link OriginUnreachableError}, which it used to share, because the
|
|
425
|
+
* two need opposite answers to "should I try again". An unreachable origin is a
|
|
426
|
+
* passing outage; an expired or mismatched certificate fails identically on
|
|
427
|
+
* every retry, and is a deployment somebody has to go and fix.
|
|
428
|
+
*
|
|
429
|
+
* The line was already drawn by status number, so the retry behaviour here was
|
|
430
|
+
* unchanged when this class appeared — but a caller reading the TYPE was told
|
|
431
|
+
* the two were the same thing, while the list beside it said they were not.
|
|
432
|
+
*
|
|
433
|
+
* The type is now what draws it: {@link isTransientForPoll} names this class in
|
|
434
|
+
* its fatal set, and 521-523 fall through to the poll (OPL-3724). Which is also
|
|
435
|
+
* why splitting it mattered more than it looked — the mandala-computer-python
|
|
436
|
+
* SDK had the same pairing and named classes in its fatal set, so a wait helper
|
|
437
|
+
* there could not tell 526 from 522 and spent its whole timeout retrying a
|
|
438
|
+
* certificate.
|
|
439
|
+
*/
|
|
440
|
+
export class OriginTLSError extends APIError {
|
|
441
|
+
name = 'OriginTLSError';
|
|
442
|
+
}
|
|
443
|
+
const BY_STATUS = {
|
|
444
|
+
401: AuthenticationError,
|
|
445
|
+
402: PlanLimitError,
|
|
446
|
+
403: PermissionDeniedError,
|
|
447
|
+
404: NotFoundError,
|
|
448
|
+
409: ConflictError,
|
|
449
|
+
// Only ever reached through errorForStatus, which cannot see the response
|
|
450
|
+
// headers and so cannot fill in `size`. Api.#error builds this one itself for
|
|
451
|
+
// that reason; the entry is here so the mapping stays complete, and so a 416
|
|
452
|
+
// arriving from anywhere else is still the right class with the platform's
|
|
453
|
+
// own message on it.
|
|
454
|
+
416: RangeNotSatisfiableError,
|
|
455
|
+
// Reached through errorForStatus only when the response headers were not in
|
|
456
|
+
// hand — Api.#error builds this one itself, for RangeNotSatisfiableError's
|
|
457
|
+
// reason: `Retry-After` is on the headers and the number is worth keeping.
|
|
458
|
+
// The entry is here so a 429 arriving from anywhere else is still the right
|
|
459
|
+
// class, which is what {@link isTransient} now asks about.
|
|
460
|
+
429: RateLimitError,
|
|
461
|
+
// The other status a proxy writes on its own, and it was the one gap left in
|
|
462
|
+
// this range: with no entry it fell through to a bare APIError, so a model
|
|
463
|
+
// read `HTTP 502` or 500 characters of nginx's HTML — the exact failure the
|
|
464
|
+
// statuses below exist to remove. It polls through isTransientForPoll — the
|
|
465
|
+
// outcome of a 502 is unknown, and a read whose outcome is unknown can simply
|
|
466
|
+
// be read again — so the wait tools reach it and replay whichever of those two
|
|
467
|
+
// it was into their give-up text. Filed with 520 because the honest answer is the same one:
|
|
468
|
+
// unknown. See BAD_GATEWAY_MESSAGE for why it is not filed with 521-523.
|
|
469
|
+
502: OriginResponseError,
|
|
470
|
+
503: UnavailableError,
|
|
471
|
+
504: GatewayTimeoutError,
|
|
472
|
+
// NOT OriginUnreachableError, which is the trap in this range: 520 means the
|
|
473
|
+
// platform WAS reached and answered unreadably. See OriginResponseError.
|
|
474
|
+
520: OriginResponseError,
|
|
475
|
+
521: OriginUnreachableError,
|
|
476
|
+
522: OriginUnreachableError,
|
|
477
|
+
523: OriginUnreachableError,
|
|
478
|
+
524: GatewayTimeoutError,
|
|
479
|
+
// Their own class, not more entries on the one above: an unreachable origin is
|
|
480
|
+
// a passing outage and these are a deployment somebody has to fix.
|
|
481
|
+
525: OriginTLSError,
|
|
482
|
+
526: OriginTLSError,
|
|
483
|
+
};
|
|
484
|
+
/**
|
|
485
|
+
* What a caller is told when a proxy abandoned the request and named nothing.
|
|
486
|
+
*
|
|
487
|
+
* Used only where the response carried no structured message of its own — see
|
|
488
|
+
* {@link platformNamed}. A 524 is generated at the edge, so that is the usual
|
|
489
|
+
* case: it carries a proxy's HTML error page or — when the request asked for
|
|
490
|
+
* JSON, as every request from this server does — nothing at all. The empty
|
|
491
|
+
* case is the dangerous one:
|
|
492
|
+
* it left the model reading the bare string `HTTP 524`, which names no cause, no
|
|
493
|
+
* culprit and no way out, and whose obvious next move is a retry that fails
|
|
494
|
+
* identically.
|
|
495
|
+
*
|
|
496
|
+
* Two statuses share this class and must NOT share all of this wording, which is
|
|
497
|
+
* why the text is built per status rather than written once. The ceiling — about
|
|
498
|
+
* two minutes, a larger `timeout_s` buying no time, `background: true` as the
|
|
499
|
+
* shape that survives it — is a fact about a 524 specifically. A 504 comes from
|
|
500
|
+
* any hop that gave up early, at no fixed deadline, and
|
|
501
|
+
* {@link isTransientForPoll} says it is worth retrying unchanged; telling its
|
|
502
|
+
* caller that retrying buys no time contradicts that and is false besides. Hedging on "if this was an exec" does
|
|
503
|
+
* not fix it, because the wrong half is the status, not the route.
|
|
504
|
+
*/
|
|
505
|
+
const GATEWAY_TIMEOUT_SHARED = 'a proxy in front of the platform gave up waiting for it to answer. Nothing was ' +
|
|
506
|
+
'cancelled: the platform never saw this deadline, so anything this request had already ' +
|
|
507
|
+
'set going carries on without it';
|
|
508
|
+
/**
|
|
509
|
+
* The 524 tail: the ceiling, and what it means for the route that meets it most.
|
|
510
|
+
*
|
|
511
|
+
* Still hedged on the route, because a screenshot or a listing can meet the same
|
|
512
|
+
* ceiling and neither takes a `timeout_s` — the wait tools do not reach a 524 at
|
|
513
|
+
* all, which is the other half of why this is safe to say here and was not safe
|
|
514
|
+
* to say for a 504.
|
|
515
|
+
*/
|
|
516
|
+
const GATEWAY_TIMEOUT_CEILING = ' — usually it has the request and is still working. Most often that is a foreground ' +
|
|
517
|
+
'exec, and if this one was, it ended this way after about two minutes however large a ' +
|
|
518
|
+
'timeout_s it was given: the ceiling belongs to the proxy, not to the platform, so on ' +
|
|
519
|
+
'that route a larger timeout_s buys no time, background: true with exec_poll is what ' +
|
|
520
|
+
'runs something slower, and the next call on that computer may report the guest agent ' +
|
|
521
|
+
'as busy with the command that outlived the request';
|
|
522
|
+
/**
|
|
523
|
+
* The 504 tail: no ceiling, no route-specific advice, and retrying is the move.
|
|
524
|
+
*
|
|
525
|
+
* A 504 can be raised by any hop, including one that never reached the platform,
|
|
526
|
+
* so it cannot promise the work is running the way a 524 can.
|
|
527
|
+
*/
|
|
528
|
+
const GATEWAY_TIMEOUT_TRANSIENT = ', though a 504 can also come from a hop that never reached it, so whether the work is ' +
|
|
529
|
+
'running is not knowable from here. No fixed deadline was hit and nothing about the ' +
|
|
530
|
+
'request needs changing: this usually clears, and the same call again is the move — ' +
|
|
531
|
+
'check before repeating anything that creates something, since the first attempt may ' +
|
|
532
|
+
'yet have landed';
|
|
533
|
+
/** The message for a gateway timeout, with only the half its status can support. */
|
|
534
|
+
function gatewayTimeoutMessage(status) {
|
|
535
|
+
return (GATEWAY_TIMEOUT_SHARED + (status === 524 ? GATEWAY_TIMEOUT_CEILING : GATEWAY_TIMEOUT_TRANSIENT));
|
|
536
|
+
}
|
|
537
|
+
/** What a caller is told when the platform's own answer arrived unreadable. */
|
|
538
|
+
const ORIGIN_RESPONSE_MESSAGE = 'the platform received the request and the exchange then broke on the way back — an ' +
|
|
539
|
+
'empty or unreadable response, a connection dropped before the headers, an origin that ' +
|
|
540
|
+
'stopped part-way. Unlike an unreachable origin, the request did arrive, so it may have ' +
|
|
541
|
+
'been carried out in full, in part, or not at all. Retrying a read costs nothing; before ' +
|
|
542
|
+
'retrying anything that creates something — a computer, a snapshot — check whether the ' +
|
|
543
|
+
'first attempt took effect, or you may end up with two of it';
|
|
544
|
+
/**
|
|
545
|
+
* What a caller is told for a 502, which is the two failures either side of it.
|
|
546
|
+
*
|
|
547
|
+
* Not ORIGIN_RESPONSE_MESSAGE, though it shares that class. A 520 is Cloudflare
|
|
548
|
+
* saying the origin answered unreadably, so "the request did arrive" is known.
|
|
549
|
+
* A 502 is any proxy saying it has no usable answer, and the two reasons —
|
|
550
|
+
* upstream replied with something invalid, upstream could not be reached — are
|
|
551
|
+
* indistinguishable from the outside. Asserting arrival would be the same shape
|
|
552
|
+
* of confident falsehood as the "nothing was started" this branch removed from
|
|
553
|
+
* 520, pointed the other way, so this says the one true thing instead.
|
|
554
|
+
*/
|
|
555
|
+
const BAD_GATEWAY_MESSAGE = 'a proxy in front of the platform had no usable answer from it — either the platform ' +
|
|
556
|
+
'replied with something the proxy could not read, or it could not be reached at all, ' +
|
|
557
|
+
'and which of those happened is not visible from here. So whether the request arrived ' +
|
|
558
|
+
'is unknown, and with it whether the work was done: retrying a read costs nothing, but ' +
|
|
559
|
+
'before retrying anything that creates something — a computer, a snapshot — check ' +
|
|
560
|
+
'whether the first attempt took effect. Usually this is a passing outage that clears';
|
|
561
|
+
/** What a caller is told when a proxy could not reach the platform at all. */
|
|
562
|
+
const ORIGIN_UNREACHABLE_MESSAGE = 'a proxy in front of the platform could not reach it. Almost always that means the ' +
|
|
563
|
+
'request was never sent, so nothing was started and there is no work on the other side ' +
|
|
564
|
+
'of this to account for — unlike a gateway timeout. Almost, rather than never, because ' +
|
|
565
|
+
'a connection can also time out after it was established, and bytes already on the wire ' +
|
|
566
|
+
'are not unsent because the answer never came back: retry a read freely, and look before ' +
|
|
567
|
+
'retrying something that creates. Usually this is the platform restarting or a short ' +
|
|
568
|
+
'outage, which clears on its own; if it persists the platform is down, and waiting is ' +
|
|
569
|
+
'the only thing that helps';
|
|
570
|
+
/** The same, for the two of those that waiting will not fix. */
|
|
571
|
+
const ORIGIN_TLS_MESSAGE = 'a proxy in front of the platform could not complete a TLS handshake with it, so the ' +
|
|
572
|
+
'request was never sent. This is a misconfigured deployment rather than a passing ' +
|
|
573
|
+
'outage — an expired or mismatched certificate fails the same way on every retry, so ' +
|
|
574
|
+
'report it rather than waiting it out';
|
|
575
|
+
/**
|
|
576
|
+
* Whether the response named this failure in the shape this surface uses.
|
|
577
|
+
*
|
|
578
|
+
* Only a JSON body with a non-empty `error` string counts. An HTML page and an
|
|
579
|
+
* empty body are an intermediary's, and both are worth discarding for the
|
|
580
|
+
* wording below; a structured message is not. "upstream unavailable before
|
|
581
|
+
* dispatch" is a more specific true thing than anything written here, and
|
|
582
|
+
* replacing it would be this server overwriting a hop that knew more than it
|
|
583
|
+
* does with a guess.
|
|
584
|
+
*
|
|
585
|
+
* Which hop wrote it is not knowable from here, and does not need to be. The
|
|
586
|
+
* test is whether SOMETHING said something specific, not whether it was the
|
|
587
|
+
* platform — a 504 can be raised by any proxy in the chain, including one in
|
|
588
|
+
* front of a MANDALA_BASE_URL this server has never seen.
|
|
589
|
+
*/
|
|
590
|
+
function platformNamed(body) {
|
|
591
|
+
return platformSaid(body) !== undefined;
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* The sentence a response carried in its own words, or `undefined` for one that
|
|
595
|
+
* carried none — the same test {@link platformNamed} asks, with the answer kept.
|
|
596
|
+
*
|
|
597
|
+
* Exported for the one caller that has to print a message the substitutions
|
|
598
|
+
* above would otherwise have written for it. A tool whose route contradicts the
|
|
599
|
+
* generic wording for a status — `window_action` and a 504, where
|
|
600
|
+
* {@link GatewayTimeoutError}'s tail says the same call again is the move and on
|
|
601
|
+
* that route it is not — cannot append its own paragraph under prose that says
|
|
602
|
+
* the opposite. It needs to know whether the sentence in `message` came from a
|
|
603
|
+
* hop that knew this request, in which case it is worth repeating, or from this
|
|
604
|
+
* file, in which case it is worth replacing (OPL-3910).
|
|
605
|
+
*
|
|
606
|
+
* Reads the BODY rather than the message, deliberately. By the time an error
|
|
607
|
+
* exists the two may differ — that is what the substitutions are — so asking the
|
|
608
|
+
* message whether it is the platform's would be asking the answer to vouch for
|
|
609
|
+
* itself.
|
|
610
|
+
*/
|
|
611
|
+
export function platformSaid(body) {
|
|
612
|
+
if (!body || typeof body !== 'object')
|
|
613
|
+
return undefined;
|
|
614
|
+
const err = body.error;
|
|
615
|
+
return typeof err === 'string' && err.length > 0 ? err : undefined;
|
|
616
|
+
}
|
|
617
|
+
/** Build the error for a status, with the platform's own message when it sent one. */
|
|
618
|
+
export function errorForStatus(status, message, body) {
|
|
619
|
+
const Cls = BY_STATUS[status] ?? APIError;
|
|
620
|
+
// The 409 that is an offer, told apart by its body. Before the substitutions
|
|
621
|
+
// below because it never wants one: the platform's sentence here is the whole
|
|
622
|
+
// explanation of what will not fit and what moving would cost, written to be
|
|
623
|
+
// read by whoever has to agree to it.
|
|
624
|
+
if (Cls === ConflictError) {
|
|
625
|
+
const offer = moveOffer(body);
|
|
626
|
+
if (offer)
|
|
627
|
+
return new MoveRequiredError(message, status, body, offer.possible);
|
|
628
|
+
}
|
|
629
|
+
// Substituted for an empty body, which says nothing, and for a proxy's HTML
|
|
630
|
+
// page, which says 500 characters of nothing. NOT for a structured message:
|
|
631
|
+
// that is the one case where the response knows more than this file does.
|
|
632
|
+
//
|
|
633
|
+
// The same guard on every branch below, because platformNamed already settles
|
|
634
|
+
// the question they were once split over. Two of these used to substitute
|
|
635
|
+
// unconditionally, on the reading that a 521-526 cannot carry the platform's
|
|
636
|
+
// account of itself — true, and beside the point. platformNamed does not ask
|
|
637
|
+
// whether the PLATFORM spoke; it asks whether anything did, precisely because
|
|
638
|
+
// a hop in front of a self-hosted MANDALA_BASE_URL is a hop this server has
|
|
639
|
+
// never seen and cannot outrank. An operator's own gateway answering 522 with
|
|
640
|
+
// `{"error":"backend pool empty; scale the worker group"}` knows more about
|
|
641
|
+
// that deployment than the generic outage prose here does, and discarding it
|
|
642
|
+
// was the very thing the 504 and 520 guards exist to prevent.
|
|
643
|
+
if (Cls === GatewayTimeoutError && !platformNamed(body)) {
|
|
644
|
+
return new GatewayTimeoutError(gatewayTimeoutMessage(status), status, body);
|
|
645
|
+
}
|
|
646
|
+
if (Cls === OriginResponseError && !platformNamed(body)) {
|
|
647
|
+
// 502 and 520 share a class and not a message: one knows the request
|
|
648
|
+
// arrived, the other cannot tell. See BAD_GATEWAY_MESSAGE.
|
|
649
|
+
const said = status === 502 ? BAD_GATEWAY_MESSAGE : ORIGIN_RESPONSE_MESSAGE;
|
|
650
|
+
return new OriginResponseError(said, status, body);
|
|
651
|
+
}
|
|
652
|
+
if (Cls === OriginTLSError && !platformNamed(body)) {
|
|
653
|
+
return new OriginTLSError(ORIGIN_TLS_MESSAGE, status, body);
|
|
654
|
+
}
|
|
655
|
+
if (Cls === OriginUnreachableError && !platformNamed(body)) {
|
|
656
|
+
return new OriginUnreachableError(ORIGIN_UNREACHABLE_MESSAGE, status, body);
|
|
657
|
+
}
|
|
658
|
+
return new Cls(message, status, body);
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* Whether an error is worth trying again without changing the request.
|
|
662
|
+
*
|
|
663
|
+
* The PUBLIC answer, exported from the package, and therefore a contract with
|
|
664
|
+
* embedders rather than a private note to this file. Its caller is a host
|
|
665
|
+
* application wrapping an arbitrary call in `if (isTransient(err)) retry()` —
|
|
666
|
+
* including one that creates something — so it names only failures that both
|
|
667
|
+
* clear on their own AND are safe to replay blind.
|
|
668
|
+
*
|
|
669
|
+
* Answered by TYPE, with no status numbers at all, which is the OPL-3724
|
|
670
|
+
* decision written down. Three clients had drifted into three mechanisms for
|
|
671
|
+
* one question: this file matched classes plus a list of numbers, the
|
|
672
|
+
* TypeScript SDK matched classes alone, and the Python SDK named the fatal
|
|
673
|
+
* exceptions and retried the rest. The status list is what let this one drift,
|
|
674
|
+
* because a number can be added to it without anyone having to say which of the
|
|
675
|
+
* three answers changed. It now reads identically in all three:
|
|
676
|
+
*
|
|
677
|
+
* - {@link ConflictError} — something is in flight that this cannot run
|
|
678
|
+
* alongside, minus the one that is a decision
|
|
679
|
+
* - {@link RateLimitError} — a cadence, and the response usually says how long
|
|
680
|
+
* - {@link UnavailableError} — a hypervisor briefly out of reach
|
|
681
|
+
* - {@link ConnectivityError} — the request never left
|
|
682
|
+
*
|
|
683
|
+
* That last line is now literally true, and it was not always. The class used
|
|
684
|
+
* to cover every transport rejection, a lost response body included, so this
|
|
685
|
+
* predicate told a caller replaying a create that the platform had not been
|
|
686
|
+
* reached when in fact it had been and the answer was what went missing.
|
|
687
|
+
* {@link ConnectivityInterruptedError} carries that case now and is excluded
|
|
688
|
+
* below — the same decision as the paragraph after this one, applied to the
|
|
689
|
+
* one class it had missed (OPL-3855).
|
|
690
|
+
*
|
|
691
|
+
* 502 and 504 USED to be here and are deliberately gone. The paragraph below
|
|
692
|
+
* had already conceded the point that removes them: both can arrive after the
|
|
693
|
+
* platform has acted, so replaying a `create_computer` through one can leave a
|
|
694
|
+
* second billable computer behind a failure that read as nothing having
|
|
695
|
+
* happened. A status that is not safe for the riskiest caller of an exported
|
|
696
|
+
* predicate does not belong in it. Nothing waits less as a result — the wait
|
|
697
|
+
* tools ask {@link isTransientForPoll}, which still rides both out.
|
|
698
|
+
*
|
|
699
|
+
* "Worth trying again" is not "the call definitely did not happen", and no
|
|
700
|
+
* predicate taking only an error can tell you the second. Even here, a 409 can
|
|
701
|
+
* be answered after a change landed. So retry reads freely, and check before
|
|
702
|
+
* repeating anything that creates something.
|
|
703
|
+
*
|
|
704
|
+
* Nor is a status enough on its own to answer it. 409 is the case: most of them
|
|
705
|
+
* are a passing state, and the move offer is a decision that no retry changes,
|
|
706
|
+
* which is why the check below leads with the type rather than the number. If a
|
|
707
|
+
* second such refusal earns a class, it belongs on that line too.
|
|
708
|
+
*
|
|
709
|
+
* One 409 could not be given a class and could not be seen from here at all: a
|
|
710
|
+
* clipboard read or write against a computer that is STOPPED does not clear on
|
|
711
|
+
* its own — a start is the fix, not another attempt — and nothing in the body
|
|
712
|
+
* told it apart from a conflict that is merely passing. The advice was to read
|
|
713
|
+
* the message, which is prose the platform is free to reword and exactly the
|
|
714
|
+
* matching OPL-3724 got three clients out of. The platform now says which kind
|
|
715
|
+
* it is, so {@link APIError.reason} is consulted BEFORE the types below, and an
|
|
716
|
+
* absent word — or one this version does not know — leaves the type answer
|
|
717
|
+
* standing unchanged (OPL-3898).
|
|
718
|
+
*
|
|
719
|
+
* One refusal is known to sit on the wrong side of that fallback, and it stays
|
|
720
|
+
* there deliberately (OPL-3909). A computer runs at most sixteen background
|
|
721
|
+
* commands, and the request for a seventeenth is refused 409 with no `reason` —
|
|
722
|
+
* correctly, since the slots may be held by servers and the platform will not
|
|
723
|
+
* advise a retry it cannot promise. The type answer therefore stands, and it
|
|
724
|
+
* says yes to something that may never clear. The alternative is to read the
|
|
725
|
+
* platform's sentence, which is the exported-contract version of the mistake
|
|
726
|
+
* OPL-3724 removed from three clients: a predicate that changes its answer when
|
|
727
|
+
* somebody rewords a message. So the next step is given where the sentence can
|
|
728
|
+
* be read safely — in `exec`, which knows it asked for a slot and which prints
|
|
729
|
+
* the platform's own words either way — and this predicate is left honest about
|
|
730
|
+
* what it can and cannot tell apart.
|
|
731
|
+
*/
|
|
732
|
+
export function isTransient(err) {
|
|
733
|
+
// A move offer is a 409 and is NOT transient — it is a decision about the
|
|
734
|
+
// size that was asked for, and the same request answers the same way forever.
|
|
735
|
+
// First, because it is a subclass of the very branch below that would say yes
|
|
736
|
+
// (OPL-3775). An embedder wrapping a resize in `if (isTransient(err)) retry()`
|
|
737
|
+
// is the caller this line is for.
|
|
738
|
+
if (err instanceof MoveRequiredError)
|
|
739
|
+
return false;
|
|
740
|
+
// A lost RESPONSE is not a request that never left, and only one of the two
|
|
741
|
+
// is safe to replay blind. Same shape as the line above and the same reason:
|
|
742
|
+
// a subclass of a branch below that would otherwise say yes (OPL-3855).
|
|
743
|
+
if (err instanceof ConnectivityInterruptedError)
|
|
744
|
+
return false;
|
|
745
|
+
// The platform's own word, ahead of the types below, because it is the more
|
|
746
|
+
// specific answer and it is the one that tells the 409 that never clears from
|
|
747
|
+
// the two that do (OPL-3898). Only an APIError carries a shape-checked one:
|
|
748
|
+
// an arbitrary exception may happen to have a `reason` property, and that is
|
|
749
|
+
// neither this protocol nor retry advice.
|
|
750
|
+
if (err instanceof APIError && err.reason !== undefined) {
|
|
751
|
+
if (REASON_CLEARS.has(err.reason))
|
|
752
|
+
return true;
|
|
753
|
+
if (REASON_PERMANENT.has(err.reason))
|
|
754
|
+
return false;
|
|
755
|
+
}
|
|
756
|
+
return (err instanceof ConflictError ||
|
|
757
|
+
err instanceof RateLimitError ||
|
|
758
|
+
err instanceof UnavailableError ||
|
|
759
|
+
err instanceof ConnectivityError);
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* The same words, a different question: worth POLLING again.
|
|
763
|
+
*
|
|
764
|
+
* Asked only by the wait tools, and deliberately not exported. They replay a
|
|
765
|
+
* `GET /computers/:id`, a `GET /moves`, a build read or an `exec 'exit 0'`
|
|
766
|
+
* probe — every one of them idempotent, every one of them under a deadline the
|
|
767
|
+
* caller set. That pair of properties is what makes this predicate generous,
|
|
768
|
+
* and it is a property of what those calls DO rather than of the error, which
|
|
769
|
+
* is exactly why it cannot be published: the same `true` handed to an embedder
|
|
770
|
+
* retrying a create means something else entirely.
|
|
771
|
+
*
|
|
772
|
+
* DENY-LIST, and the inversion is the second half of the OPL-3724 decision.
|
|
773
|
+
* Where {@link isTransient} names what may be retried, this names what may not
|
|
774
|
+
* and polls through everything else. The polarity follows from who pays for a
|
|
775
|
+
* wrong answer. Retrying something unretryable costs one poll interval and,
|
|
776
|
+
* at worst, the deadline the caller chose. NOT retrying something that would
|
|
777
|
+
* have cleared costs a wait that reports a machine as unreachable while it was
|
|
778
|
+
* coming up — and every status the edge invents next year lands in that second
|
|
779
|
+
* category under an allow-list, silently, until somebody notices and adds a
|
|
780
|
+
* number. This way round, an unmapped 5xx is ridden out, which is what a poll
|
|
781
|
+
* loop was for.
|
|
782
|
+
*
|
|
783
|
+
* The line is REQUEST versus MOMENT. A failure describing the request answers
|
|
784
|
+
* the same way forever and is fatal here; a failure describing the moment is
|
|
785
|
+
* what a poll exists to outlast.
|
|
786
|
+
*
|
|
787
|
+
* Fatal, therefore:
|
|
788
|
+
*
|
|
789
|
+
* - anything that is not a failed REQUEST. That is the floor, and a deny-list
|
|
790
|
+
* needs one: only {@link APIError} and {@link ConnectivityError} describe an
|
|
791
|
+
* exchange with the platform that did not work, and only those can be worth
|
|
792
|
+
* making again. A `TypeError` from a bug in this file is not the platform
|
|
793
|
+
* being slow, and riding one out spends the caller's deadline before
|
|
794
|
+
* reporting the wrong cause.
|
|
795
|
+
*
|
|
796
|
+
* A bare {@link MandalaError} is caught by the same floor, and that is the
|
|
797
|
+
* half worth spelling out. `Api` raises them for a response that arrived and
|
|
798
|
+
* made no sense — "expected JSON from GET /computers/:id, got: <html>" — and
|
|
799
|
+
* a poll loop raises them as verdicts about a poll that SUCCEEDED. Neither is
|
|
800
|
+
* a moment to outlast: one is a defect and the other is an answer. Polling
|
|
801
|
+
* through a verdict is an infinite loop with a deadline on it, which is
|
|
802
|
+
* exactly what the TypeScript SDK's suite caught when this predicate was
|
|
803
|
+
* ported there with `MandalaError` as its floor.
|
|
804
|
+
*
|
|
805
|
+
* {@link ConnectivityInterruptedError} passes that floor and is meant to, on
|
|
806
|
+
* the strength of what the callers here DO. It says the outcome of one
|
|
807
|
+
* request is unknown; every request this predicate guards is a read, and a
|
|
808
|
+
* read whose outcome is unknown can be read again. Fatal to
|
|
809
|
+
* {@link isTransient} and transparent here is the whole point of there being
|
|
810
|
+
* two predicates.
|
|
811
|
+
* - {@link CancelledError} — the caller hung up. Excluded by the floor above,
|
|
812
|
+
* since it is neither, and retrying something nobody is waiting for is what
|
|
813
|
+
* `with(signal)` exists to stop.
|
|
814
|
+
* - {@link MoveRequiredError} — a decision about the size that was asked for.
|
|
815
|
+
* - {@link OriginTLSError} (525, 526) — a certificate the edge and the platform
|
|
816
|
+
* cannot agree on fails identically on every retry, so waiting one out spends
|
|
817
|
+
* the whole deadline to report the wrong cause. Its own message says to go and
|
|
818
|
+
* fix the deployment; this is what makes that true.
|
|
819
|
+
* - 524 — reached only by holding a request open past the edge's ceiling, so an
|
|
820
|
+
* identical retry reproduces it at the same place. It shares
|
|
821
|
+
* {@link GatewayTimeoutError} with 504, which is retryable, and that is why
|
|
822
|
+
* this one status is still matched by NUMBER: the type cannot separate them.
|
|
823
|
+
* - anything below 500 that is not named. A 4xx describes the request — a bad
|
|
824
|
+
* body, a revoked key, a plan limit, a deleted id, an offset past the end of a
|
|
825
|
+
* file — and repeating it unchanged cannot change the answer. Three are named
|
|
826
|
+
* because they describe the moment instead: 409 (something in flight), 429 (a
|
|
827
|
+
* cadence), and 408, which RFC 9110 defines as a request the client may repeat
|
|
828
|
+
* unchanged and which the edge in front of this surface does emit.
|
|
829
|
+
*
|
|
830
|
+
* A 3xx goes with the 4xx, which is why the test is `>= 500` rather than "not
|
|
831
|
+
* a 4xx". `Api` treats every non-2xx as an error and does not follow
|
|
832
|
+
* redirects, so a MANDALA_BASE_URL missing its trailing path answers 301 — and
|
|
833
|
+
* under a 4xx-only rule that was polled until the deadline, ending in a
|
|
834
|
+
* give-up that named nothing about the redirect. The mandala-computer-python
|
|
835
|
+
* SDK found that one; this is the same rule, and it is why all three now say
|
|
836
|
+
* `>= 500`.
|
|
837
|
+
*
|
|
838
|
+
* {@link APIError.reason} is deliberately NOT consulted here, and that is the
|
|
839
|
+
* one place this predicate and {@link isTransient} part company (OPL-3898).
|
|
840
|
+
* `unavailable` means the computer is not running, which is a permanent answer
|
|
841
|
+
* to whoever asked — and a poll under a deadline is the one caller for whom it
|
|
842
|
+
* may not be, since a computer coming up passes through it. The same generosity
|
|
843
|
+
* as every unmapped 5xx below, for the same reason: this only ever replays a
|
|
844
|
+
* read, and the loops above return a refusal of their own the moment the status
|
|
845
|
+
* they are watching says stopped or suspended. mandala-computer-python's
|
|
846
|
+
* `_is_transient_for_poll` draws the line in the same place.
|
|
847
|
+
*
|
|
848
|
+
* Everything at 5xx polls through, 502 and 520-523 included: they mean the
|
|
849
|
+
* outcome is unknown, and a read whose outcome is unknown can simply be read
|
|
850
|
+
* again.
|
|
851
|
+
*
|
|
852
|
+
* 5xx has an upper bound as well as a lower one, and it is not decoration. The
|
|
853
|
+
* HTTP parser under `fetch` accepts any three digits, so a broken or hostile
|
|
854
|
+
* origin can answer 700 — which `>= 500` alone called a passing moment and
|
|
855
|
+
* polled until the caller's deadline (Codex adversarial review, OPL-3724).
|
|
856
|
+
*/
|
|
857
|
+
export function isTransientForPoll(err) {
|
|
858
|
+
if (!(err instanceof APIError) && !(err instanceof ConnectivityError))
|
|
859
|
+
return false;
|
|
860
|
+
if (err instanceof MoveRequiredError)
|
|
861
|
+
return false;
|
|
862
|
+
if (err instanceof OriginTLSError)
|
|
863
|
+
return false;
|
|
864
|
+
if (err instanceof APIError) {
|
|
865
|
+
if (err.status === 524)
|
|
866
|
+
return false;
|
|
867
|
+
if (err.status === 408 || err.status === 409 || err.status === 429)
|
|
868
|
+
return true;
|
|
869
|
+
return err.status >= 500 && err.status < 600;
|
|
870
|
+
}
|
|
871
|
+
return true;
|
|
872
|
+
}
|
|
873
|
+
//# sourceMappingURL=errors.js.map
|