@prized/sdk 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -11
- package/dist/cjs/commands.d.ts +3 -1
- package/dist/cjs/commands.js +45 -7
- package/dist/cjs/files.d.ts +6 -5
- package/dist/cjs/files.js +8 -5
- package/dist/cjs/http.d.ts +48 -5
- package/dist/cjs/http.js +147 -19
- package/dist/cjs/outcome.d.ts +9 -0
- package/dist/cjs/outcome.js +21 -1
- package/dist/cjs/output.js +1 -0
- package/dist/cjs/sandbox.d.ts +3 -2
- package/dist/cjs/sandbox.js +12 -3
- package/dist/cjs/shell.d.ts +2 -1
- package/dist/cjs/shell.js +2 -1
- package/dist/cjs/toolbox.d.ts +2 -0
- package/dist/cjs/toolbox.js +1 -0
- package/dist/cjs/version.d.ts +1 -1
- package/dist/cjs/version.js +1 -1
- package/dist/esm/commands.d.ts +3 -1
- package/dist/esm/commands.js +46 -8
- package/dist/esm/files.d.ts +6 -5
- package/dist/esm/files.js +9 -6
- package/dist/esm/http.d.ts +48 -5
- package/dist/esm/http.js +146 -18
- package/dist/esm/outcome.d.ts +9 -0
- package/dist/esm/outcome.js +20 -1
- package/dist/esm/output.js +2 -1
- package/dist/esm/sandbox.d.ts +3 -2
- package/dist/esm/sandbox.js +12 -3
- package/dist/esm/shell.d.ts +2 -1
- package/dist/esm/shell.js +2 -1
- package/dist/esm/toolbox.d.ts +2 -0
- package/dist/esm/toolbox.js +1 -0
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -60,6 +60,8 @@ to fewer permissions needs **Sandboxes: Write** to create, run commands and
|
|
|
60
60
|
move files; **Sandboxes: Read** is enough for `list`, `connect`, `refresh` and
|
|
61
61
|
`templates`.
|
|
62
62
|
|
|
63
|
+
Your coding agent can install the Prized skill with `npx skills add prized-dev/skills -g -y` (it should ask you first), so it knows when and how to use Prized the next time you ask for a sandbox.
|
|
64
|
+
|
|
63
65
|
Every entry point (`create`, `connect`, `list`, `deleteByLabels`, `templates`) takes the same
|
|
64
66
|
client options:
|
|
65
67
|
|
|
@@ -72,7 +74,7 @@ client options:
|
|
|
72
74
|
| `maxRetries` | `3` | See [Retries](#retries-and-timeouts). |
|
|
73
75
|
| `requestTimeoutMs` | `60000` | Per API request. |
|
|
74
76
|
| `pollIntervalMs` | `500` | The longest the SDK's own polling goes between checks of the sandbox (the first come sooner). It does not cover the request itself, or the time the API holds a create, pause or resume for you (up to 8 seconds). |
|
|
75
|
-
| `pollOutageMs` | `120000` | How long a wait keeps polling through a dropped connection (or a 5xx, or a rate limit) before it gives up: a streamed or long `exec` then stops the command, a handle's `wait()` throws. `exec` and `wait()` take it per call too. |
|
|
77
|
+
| `pollOutageMs` | `120000` | How long a wait keeps polling through a dropped connection (or a 5xx, or a rate limit) before it gives up: a streamed or long `exec` then stops the command, a handle's `wait()` throws. A background, streamed or long `exec` keeps sending its start for as long. `exec` and `wait()` take it per call too. |
|
|
76
78
|
|
|
77
79
|
Your API key goes with every request, so a plain `http` URL to a host other
|
|
78
80
|
than this machine is refused with `invalid_argument` before anything is sent,
|
|
@@ -181,8 +183,10 @@ await Promise.all(mine.map((s) => s.delete()));
|
|
|
181
183
|
### `Sandbox.deleteByLabels(labels, options?)`
|
|
182
184
|
|
|
183
185
|
Deletes every sandbox carrying all of `labels` (at least one) in one call and
|
|
184
|
-
resolves with their ids.
|
|
185
|
-
|
|
186
|
+
resolves with their ids. Each request selects the current matches, including
|
|
187
|
+
sandboxes created since an earlier request. If an answer is lost, the SDK
|
|
188
|
+
throws without repeating the delete; list the matches before deleting again.
|
|
189
|
+
A sandbox being deleted still counts
|
|
186
190
|
toward your quota until `Sandbox.list({ labels })` no longer returns it.
|
|
187
191
|
|
|
188
192
|
```ts
|
|
@@ -287,7 +291,9 @@ off by an idle connection and its output is never truncated. The result is
|
|
|
287
291
|
the same either way. If the connection drops meanwhile, the SDK keeps
|
|
288
292
|
polling for up to 2 minutes (`pollOutageMs`, on the call or the client)
|
|
289
293
|
before it gives up and stops the command; a refused key, or an answer that
|
|
290
|
-
settles it (the sandbox deleted or not running), stops it at once.
|
|
294
|
+
settles it (the sandbox deleted or not running), stops it at once. Its
|
|
295
|
+
start rides one out the same way: it is sent again for up to `pollOutageMs`,
|
|
296
|
+
and a resend finds the first start, so the command runs once.
|
|
291
297
|
Background and streamed commands run with `PYTHONUNBUFFERED=1` unless
|
|
292
298
|
their environment already sets it (`env: { PYTHONUNBUFFERED: '' }` keeps
|
|
293
299
|
Python's buffering), so a Python program's output arrives as it prints.
|
|
@@ -301,7 +307,11 @@ you need into your home: Python packages into a venv
|
|
|
301
307
|
|
|
302
308
|
## Background commands
|
|
303
309
|
|
|
304
|
-
`background: true` starts the command and returns a `CommandHandle`
|
|
310
|
+
`background: true` starts the command and returns a `CommandHandle` once it
|
|
311
|
+
has started. A start that meets a dropped connection or a 5xx is sent again
|
|
312
|
+
for up to `pollOutageMs` once its own retries run out (a resend finds the
|
|
313
|
+
first start, so the command runs once), so the call can take that long while
|
|
314
|
+
the connection is down.
|
|
305
315
|
The command runs in its own session: it keeps running after your process
|
|
306
316
|
exits, survives a pause and resume, and while it runs, a kill reaches every
|
|
307
317
|
process it started (not one that left its process group, such as a daemon).
|
|
@@ -565,18 +575,21 @@ try {
|
|
|
565
575
|
`resume`, `fork` and `delete` (the request, its retries and the wait, its
|
|
566
576
|
polls included; with `wait: false` too), twice `requestTimeoutMs` (2
|
|
567
577
|
minutes by default) for `connect`, `list`, `templates`, `refresh` and
|
|
568
|
-
`keepAlive`, and 10 minutes for a file transfer
|
|
578
|
+
`keepAlive`, and 10 minutes for a file transfer, whose attempt is given up
|
|
579
|
+
after 2 minutes with no bytes moving (a dropped connection) and sent again
|
|
580
|
+
by the rules below (on Node's own `fetch`; elsewhere, or with a `fetch` you
|
|
581
|
+
pass, an upload is watched only once its answer starts). `Retry-After` is honoured
|
|
569
582
|
up to a minute. A retry that could not start in time is not sent: the call
|
|
570
583
|
throws `timeout`. A wait's last poll goes out just before its deadline.
|
|
571
|
-
- A command, a `files.remove
|
|
584
|
+
- A command, a `files.remove`, a `kill` or `deleteByLabels` is sent again only when it
|
|
572
585
|
provably never arrived: the connection never opened, or the answer says so
|
|
573
586
|
(rate limited, the sandbox not ready). If an answer is lost after it was
|
|
574
587
|
delivered, the SDK throws (`sandbox_unreachable`, `network_error`,
|
|
575
|
-
`timeout`) rather than risk
|
|
588
|
+
`timeout`) rather than risk repeating the operation on work that changed.
|
|
576
589
|
- A file write (`files.write`, `files.upload`) goes up under a temporary name
|
|
577
590
|
beside the file, which is sent again like a read (a lost answer, a 502, 503
|
|
578
|
-
or 504), every copy to the same name and
|
|
579
|
-
|
|
591
|
+
or 504, 2 minutes with no bytes moving), every copy to the same name and
|
|
592
|
+
none after the transfer's 10 minutes. One command that is never
|
|
580
593
|
sent again then puts it in place: if that command's answer is lost, the SDK
|
|
581
594
|
throws rather than risk overwriting what the sandbox wrote to the file since.
|
|
582
595
|
- Did it run? When a command answered in one call (`exec` without
|
|
@@ -594,7 +607,8 @@ try {
|
|
|
594
607
|
says the same of its signal
|
|
595
608
|
(`status()` tells you whether the command is still running). A background
|
|
596
609
|
command (or a streamed or long `exec`, which runs as one) whose start got
|
|
597
|
-
no clear answer is stopped by the SDK, and
|
|
610
|
+
no clear answer for longer than `pollOutageMs` is stopped by the SDK, and
|
|
611
|
+
the error says so, or, when the
|
|
598
612
|
SDK could not confirm the stop, that it may still be running and under
|
|
599
613
|
which run id. So is a streamed or long `exec` whose polling fails while
|
|
600
614
|
it runs (a dropped connection or a 5xx for longer than `pollOutageMs`, a
|
package/dist/cjs/commands.d.ts
CHANGED
|
@@ -104,7 +104,9 @@ export interface ExecOptions {
|
|
|
104
104
|
* its polls fail (no answer, a 5xx, a rate limit), in ms, before it gives
|
|
105
105
|
* up and stops the command. Default: the client's `pollOutageMs`
|
|
106
106
|
* (120 000). A refused key, or an answer that settles the run (the
|
|
107
|
-
* sandbox deleted or not running, the run gone), stops it at once.
|
|
107
|
+
* sandbox deleted or not running, the run gone), stops it at once. The
|
|
108
|
+
* start of a background, streamed or long command is sent again for as
|
|
109
|
+
* long (a resend finds the first start, so the command runs once).
|
|
108
110
|
*/
|
|
109
111
|
pollOutageMs?: number;
|
|
110
112
|
}
|
package/dist/cjs/commands.js
CHANGED
|
@@ -477,7 +477,48 @@ async function cleanupOnceGone(toolbox, id) {
|
|
|
477
477
|
}
|
|
478
478
|
}
|
|
479
479
|
}
|
|
480
|
-
|
|
480
|
+
/**
|
|
481
|
+
* Send a start script. It is keyed by a run id the SDK chose, so a resend
|
|
482
|
+
* finds the first start instead of starting the command twice. Once one
|
|
483
|
+
* copy's retries run out, it is sent again through an outage of up to
|
|
484
|
+
* `outageMs` (pollOutageMs), paced as a wait's polls are (Outage): an edge
|
|
485
|
+
* that restarts mid-start does not cost the command, as it does not cost a
|
|
486
|
+
* running one its wait. Each copy's error counts the copies before it
|
|
487
|
+
* (`acted`), so one that gives up says the command may have started when any
|
|
488
|
+
* copy may have reached the sandbox. The Python SDK's _send_start.
|
|
489
|
+
*/
|
|
490
|
+
async function sendStart(toolbox, req, signal, outageMs) {
|
|
491
|
+
const config = toolbox.config;
|
|
492
|
+
const outage = new Outage(outageMs);
|
|
493
|
+
// Whether an earlier copy may have reached the sandbox: an abort between
|
|
494
|
+
// copies then says the command may have started, else that it never did.
|
|
495
|
+
let acted = false;
|
|
496
|
+
const aborted = (cause) => (0, outcome_js_1.withAbort)(new errors_js_1.PrizedError('aborted', null, { sandboxId: toolbox.sandboxId, cause }), (0, outcome_js_1.startEffect)(), acted ? 'maybe' : 'never');
|
|
497
|
+
for (;;) {
|
|
498
|
+
const sentAt = config.now();
|
|
499
|
+
try {
|
|
500
|
+
return await (0, toolbox_js_1.rawExec)(toolbox, req, { retry: 'safe', signal, deadline: outage.deadline(sentAt), effect: (0, outcome_js_1.startEffect)(), acted });
|
|
501
|
+
}
|
|
502
|
+
catch (error) {
|
|
503
|
+
if (!(error instanceof errors_js_1.PrizedError))
|
|
504
|
+
throw error;
|
|
505
|
+
acted = acted || (0, outcome_js_1.mayHaveActed)(error);
|
|
506
|
+
if (signal?.aborted)
|
|
507
|
+
throw error;
|
|
508
|
+
const step = outage.failed(error, sentAt, config.now(), (0, http_js_1.cutByDeadline)(error), (tries) => (0, http_js_1.backoffMs)(config, tries));
|
|
509
|
+
// What a copy met, not the window's cut.
|
|
510
|
+
if (step.pause === null)
|
|
511
|
+
throw step.failure === error ? error : (0, outcome_js_1.startGaveUp)(step.failure, acted);
|
|
512
|
+
try {
|
|
513
|
+
await config.sleep(step.pause, signal);
|
|
514
|
+
}
|
|
515
|
+
catch (abort) {
|
|
516
|
+
throw abort instanceof errors_js_1.PrizedError && abort.code === 'aborted' ? aborted(step.failure) : abort;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
async function startBackground(toolbox, command, cwd, env, timeoutMs, signal, unbuffered, outageMs) {
|
|
481
522
|
// Nothing sent yet: nothing to stop.
|
|
482
523
|
if (signal?.aborted)
|
|
483
524
|
throw (0, outcome_js_1.withOutcome)(new errors_js_1.PrizedError('aborted', null, { sandboxId: toolbox.sandboxId }), (0, outcome_js_1.startEffect)(), 'never');
|
|
@@ -489,10 +530,7 @@ async function startBackground(toolbox, command, cwd, env, timeoutMs, signal, un
|
|
|
489
530
|
// Launched, but no pid came within the start's own wait: it may yet run.
|
|
490
531
|
let unsettled = false;
|
|
491
532
|
try {
|
|
492
|
-
const result = await (
|
|
493
|
-
// Keyed by a run id this call chose, so a resend finds the first start
|
|
494
|
-
// instead of starting the command twice.
|
|
495
|
-
{ retry: 'safe', signal, effect: (0, outcome_js_1.startEffect)() });
|
|
533
|
+
const result = await sendStart(toolbox, { command: (0, shell_js_1.startScript)(id, command, timeoutMs === null ? null : timeoutMs / 1000, unbuffered), cwd, env, timeoutMs: 60_000 }, signal, outageMs);
|
|
496
534
|
pid = Number(result.stdout.trim());
|
|
497
535
|
if (result.exitCode !== 0 || !Number.isInteger(pid) || pid <= 0) {
|
|
498
536
|
unsettled = result.exitCode === shell_js_1.NO_PID_EXIT;
|
|
@@ -538,7 +576,7 @@ async function execCommand(toolbox, command, options = {}) {
|
|
|
538
576
|
const cwd = options.cwd === undefined ? undefined : (0, shell_js_1.sandboxPath)(options.cwd);
|
|
539
577
|
if (options.background) {
|
|
540
578
|
const limit = options.timeoutMs ? options.timeoutMs : null;
|
|
541
|
-
const handle = await startBackground(toolbox, text, cwd, options.env, limit, options.signal, true);
|
|
579
|
+
const handle = await startBackground(toolbox, text, cwd, options.env, limit, options.signal, true, outageMs);
|
|
542
580
|
handle.timeoutMs = limit;
|
|
543
581
|
return handle;
|
|
544
582
|
}
|
|
@@ -546,7 +584,7 @@ async function execCommand(toolbox, command, options = {}) {
|
|
|
546
584
|
const streamed = options.onStdout !== undefined || options.onStderr !== undefined;
|
|
547
585
|
if (streamed || timeoutMs === 0 || timeoutMs > exports.MAX_BLOCKING_EXEC_MS) {
|
|
548
586
|
const limit = timeoutMs === 0 ? null : timeoutMs;
|
|
549
|
-
const handle = await startBackground(toolbox, text, cwd, options.env, limit, options.signal, streamed);
|
|
587
|
+
const handle = await startBackground(toolbox, text, cwd, options.env, limit, options.signal, streamed, outageMs);
|
|
550
588
|
const waitOptions = {};
|
|
551
589
|
if (options.onStdout)
|
|
552
590
|
waitOptions.onStdout = options.onStdout;
|
package/dist/cjs/files.d.ts
CHANGED
|
@@ -13,9 +13,10 @@ export interface WriteOptions {
|
|
|
13
13
|
}
|
|
14
14
|
/**
|
|
15
15
|
* The sandbox's file transfers take at most this long, retries included:
|
|
16
|
-
* the edge's own limit on one transfer.
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* the edge's own limit on one transfer. An attempt that moves no bytes for
|
|
17
|
+
* timing.transferStallMs (2 minutes) is given up; a download, or a write's
|
|
18
|
+
* staged upload, is then sent again (as after a lost answer or a 502, 503 or
|
|
19
|
+
* 504) while time is left.
|
|
19
20
|
*/
|
|
20
21
|
export declare const FILE_TIMEOUT_MS: number;
|
|
21
22
|
/** The publish script's own exits (the Python SDK's too). */
|
|
@@ -60,8 +61,8 @@ export declare class Files {
|
|
|
60
61
|
* stays executable; a new file gets the sandbox's default (0600 in
|
|
61
62
|
* prized-base-v1 and prized-agent-v1). `mode` sets the permission bits either way.
|
|
62
63
|
* The bytes go up under a temporary name beside the file, sent again if
|
|
63
|
-
* need be (a lost answer, a 502, 503 or 504
|
|
64
|
-
* transfer's 10 minutes),
|
|
64
|
+
* need be (a lost answer, a 502, 503 or 504, 2 minutes with no bytes
|
|
65
|
+
* moving; all copies within the transfer's 10 minutes),
|
|
65
66
|
* and one command that is never sent again puts them in place: a write
|
|
66
67
|
* whose answer to that command was lost throws, since it may have landed
|
|
67
68
|
* and the sandbox written the file since (read it back, or write again,
|
package/dist/cjs/files.js
CHANGED
|
@@ -11,9 +11,10 @@ const shell_js_1 = require("./shell.js");
|
|
|
11
11
|
const toolbox_js_1 = require("./toolbox.js");
|
|
12
12
|
/**
|
|
13
13
|
* The sandbox's file transfers take at most this long, retries included:
|
|
14
|
-
* the edge's own limit on one transfer.
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* the edge's own limit on one transfer. An attempt that moves no bytes for
|
|
15
|
+
* timing.transferStallMs (2 minutes) is given up; a download, or a write's
|
|
16
|
+
* staged upload, is then sent again (as after a lost answer or a 502, 503 or
|
|
17
|
+
* 504) while time is left.
|
|
17
18
|
*/
|
|
18
19
|
exports.FILE_TIMEOUT_MS = 10 * 60_000;
|
|
19
20
|
function byteSize(data) {
|
|
@@ -168,6 +169,7 @@ class Files {
|
|
|
168
169
|
retry: 'safe',
|
|
169
170
|
read: 'bytes',
|
|
170
171
|
timeoutMs: exports.FILE_TIMEOUT_MS,
|
|
172
|
+
stallMs: http_js_1.timing.transferStallMs,
|
|
171
173
|
deadline: this.toolbox.config.now() + exports.FILE_TIMEOUT_MS,
|
|
172
174
|
sandboxId: this.toolbox.sandboxId,
|
|
173
175
|
});
|
|
@@ -180,8 +182,8 @@ class Files {
|
|
|
180
182
|
* stays executable; a new file gets the sandbox's default (0600 in
|
|
181
183
|
* prized-base-v1 and prized-agent-v1). `mode` sets the permission bits either way.
|
|
182
184
|
* The bytes go up under a temporary name beside the file, sent again if
|
|
183
|
-
* need be (a lost answer, a 502, 503 or 504
|
|
184
|
-
* transfer's 10 minutes),
|
|
185
|
+
* need be (a lost answer, a 502, 503 or 504, 2 minutes with no bytes
|
|
186
|
+
* moving; all copies within the transfer's 10 minutes),
|
|
185
187
|
* and one command that is never sent again puts them in place: a write
|
|
186
188
|
* whose answer to that command was lost throws, since it may have landed
|
|
187
189
|
* and the sandbox written the file since (read it back, or write again,
|
|
@@ -261,6 +263,7 @@ class Files {
|
|
|
261
263
|
retry,
|
|
262
264
|
effect,
|
|
263
265
|
timeoutMs: exports.FILE_TIMEOUT_MS,
|
|
266
|
+
stallMs: http_js_1.timing.transferStallMs,
|
|
264
267
|
deadline: this.toolbox.config.now() + exports.FILE_TIMEOUT_MS,
|
|
265
268
|
sandboxId: this.toolbox.sandboxId,
|
|
266
269
|
});
|
package/dist/cjs/http.d.ts
CHANGED
|
@@ -32,7 +32,9 @@ export interface ClientOptions {
|
|
|
32
32
|
* rate limit), in ms, before it gives up: a streamed or long `exec` then
|
|
33
33
|
* stops the command, a handle's `wait()` throws. Default 120 000. `0`
|
|
34
34
|
* gives up once one poll's retries run out. `exec` and `wait()` take it
|
|
35
|
-
* per call too.
|
|
35
|
+
* per call too. The start of a background, streamed or long `exec` is
|
|
36
|
+
* sent again for as long, so an edge that restarts mid-start does not
|
|
37
|
+
* cost the command.
|
|
36
38
|
*/
|
|
37
39
|
pollOutageMs?: number;
|
|
38
40
|
/** Sleep seam (tests inject one that never really waits). */
|
|
@@ -54,13 +56,21 @@ export interface Config {
|
|
|
54
56
|
sleep: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
55
57
|
now: () => number;
|
|
56
58
|
random: () => number;
|
|
59
|
+
/**
|
|
60
|
+
* Whether an upload's body goes out as a stream the SDK feeds piece by
|
|
61
|
+
* piece, so a stalled one is seen (RequestSpec.stallMs): Node's own fetch
|
|
62
|
+
* only, which asks for each piece once the last has gone into the socket.
|
|
63
|
+
* Elsewhere (another runtime, a fetch you pass) the body goes as it is.
|
|
64
|
+
*/
|
|
65
|
+
streamBodies: boolean;
|
|
57
66
|
}
|
|
58
67
|
export declare function abortableSleep(ms: number, signal?: AbortSignal): Promise<void>;
|
|
59
68
|
/**
|
|
60
69
|
* How long a wait (a streamed or long exec, a handle's wait()) keeps polling
|
|
61
70
|
* while its polls fail before it gives up (pollOutageMs), and a streamed or
|
|
62
|
-
* long exec stops its command
|
|
63
|
-
* or an edge that restarts
|
|
71
|
+
* long exec stops its command, and how long a start is sent again: long
|
|
72
|
+
* enough to ride out a dropped connection or an edge that restarts (about
|
|
73
|
+
* 70 s, the last time it crashed). The Python SDK's DEFAULT_POLL_OUTAGE.
|
|
64
74
|
*/
|
|
65
75
|
export declare const DEFAULT_POLL_OUTAGE_MS = 120000;
|
|
66
76
|
/** A pollOutageMs given (a number of ms ≥ 0), or undefined. */
|
|
@@ -97,8 +107,8 @@ export declare function warmEdge(config: Config): Promise<void>;
|
|
|
97
107
|
* Retries network failures, 429/502/503/504, and everything `exec`
|
|
98
108
|
* retries (the edge's 500 control_plane_unavailable among them). A GET
|
|
99
109
|
* retries any 500 too (the API's `internal`).
|
|
100
|
-
* - `exec`: a command, an upload
|
|
101
|
-
*
|
|
110
|
+
* - `exec`: a command, an upload, a remove or a label-selected delete:
|
|
111
|
+
* a second copy could affect later work. Retried only when it
|
|
102
112
|
* provably never arrived: a connection that never opened, or an answer
|
|
103
113
|
* saying so (the edge's rate limit, its lookup failing). A lost answer
|
|
104
114
|
* is never retried: it may already have run.
|
|
@@ -139,6 +149,14 @@ export interface RequestSpec {
|
|
|
139
149
|
sandboxId?: string | null;
|
|
140
150
|
/** How to read a 2xx body. Default `json`. */
|
|
141
151
|
read?: 'json' | 'bytes' | 'none';
|
|
152
|
+
/**
|
|
153
|
+
* A file transfer's no-progress bound: an attempt that moves no bytes
|
|
154
|
+
* either way for this long (a connection dropped without a reset) ends as
|
|
155
|
+
* a timeout, sent again by `retry`'s rules while `deadline` allows. An
|
|
156
|
+
* upload's sending is watched only where its body is streamed
|
|
157
|
+
* (Config.streamBodies); its answer always is.
|
|
158
|
+
*/
|
|
159
|
+
stallMs?: number;
|
|
142
160
|
/**
|
|
143
161
|
* Told of every attempt that failed (no answer, an error answer, or a 2xx
|
|
144
162
|
* that could not be read), before it is retried or thrown. A create or
|
|
@@ -153,6 +171,12 @@ export interface RequestSpec {
|
|
|
153
171
|
* its error says so too.
|
|
154
172
|
*/
|
|
155
173
|
effect?: Effect;
|
|
174
|
+
/**
|
|
175
|
+
* An earlier copy of this keyed request may have reached the sandbox (the
|
|
176
|
+
* background start, sent again through an outage), so its error says so
|
|
177
|
+
* too.
|
|
178
|
+
*/
|
|
179
|
+
acted?: boolean;
|
|
156
180
|
}
|
|
157
181
|
export interface HttpResult {
|
|
158
182
|
status: number;
|
|
@@ -215,6 +239,25 @@ export declare class Polls {
|
|
|
215
239
|
}
|
|
216
240
|
/** Delay before retry number `attempt` (0-based): exponential with jitter. */
|
|
217
241
|
export declare function backoffMs(config: Config, attempt: number): number;
|
|
242
|
+
/**
|
|
243
|
+
* How long a file transfer may move no bytes before the attempt is given up
|
|
244
|
+
* (sdk/retry-spec.json transferStallMs; the Python SDK's TRANSFER_STALL).
|
|
245
|
+
* The edge holds a request about 20 s at most before it reads the body (its
|
|
246
|
+
* admission queue, then waking a paused sandbox), and ends a body that brings
|
|
247
|
+
* nothing for 60 s itself: kept above that, a stalled copy is over at the
|
|
248
|
+
* edge before the next one goes to the same staged name, so the two never
|
|
249
|
+
* race. Once an upload's last byte is handed over, its answer is waited for
|
|
250
|
+
* that and a second more for every uploadAnswerBytesPerSecond it sent: what
|
|
251
|
+
* the socket's buffers, or a proxy that takes a whole body before it sends it
|
|
252
|
+
* on, still hold moves unseen, and must not read as a stall (the Python SDK's
|
|
253
|
+
* UPLOAD_ANSWER_RATE). Mutable for tests only.
|
|
254
|
+
*/
|
|
255
|
+
export declare const timing: {
|
|
256
|
+
transferStallMs: number;
|
|
257
|
+
uploadAnswerBytesPerSecond: number;
|
|
258
|
+
};
|
|
259
|
+
/** A streamed upload goes out this many bytes at a time. */
|
|
260
|
+
export declare const UPLOAD_CHUNK: number;
|
|
218
261
|
/**
|
|
219
262
|
* What an error answer (not a 2xx: request() judges those) says about
|
|
220
263
|
* whether the sandbox acted: `never` for the codes that prove the request
|
package/dist/cjs/http.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.Polls = exports.LAST_LOOK_MAX_MS = exports.LAST_LOOK_MS = exports.MAX_RETRY_AFTER_MS = exports.MAX_BACKOFF_MS = exports.BACKOFF_FIRST_MS = exports.DEFAULT_POLL_OUTAGE_MS = exports.DEFAULT_EDGE_URL = exports.DEFAULT_BASE_URL = void 0;
|
|
3
|
+
exports.UPLOAD_CHUNK = exports.timing = exports.Polls = exports.LAST_LOOK_MAX_MS = exports.LAST_LOOK_MS = exports.MAX_RETRY_AFTER_MS = exports.MAX_BACKOFF_MS = exports.BACKOFF_FIRST_MS = exports.DEFAULT_POLL_OUTAGE_MS = exports.DEFAULT_EDGE_URL = exports.DEFAULT_BASE_URL = void 0;
|
|
4
4
|
exports.abortableSleep = abortableSleep;
|
|
5
5
|
exports.validOutage = validOutage;
|
|
6
6
|
exports.resolveConfig = resolveConfig;
|
|
@@ -21,6 +21,11 @@ const outcome_js_1 = require("./outcome.js");
|
|
|
21
21
|
const version_js_1 = require("./version.js");
|
|
22
22
|
exports.DEFAULT_BASE_URL = 'https://api.prized.dev';
|
|
23
23
|
exports.DEFAULT_EDGE_URL = 'https://term.prized.dev';
|
|
24
|
+
/** Node's own fetch (not Bun's or Deno's, which also say they are Node), with web streams. */
|
|
25
|
+
function nodeFetch() {
|
|
26
|
+
const versions = globalThis.process?.versions;
|
|
27
|
+
return typeof versions?.node === 'string' && versions.bun === undefined && versions.deno === undefined && typeof ReadableStream === 'function';
|
|
28
|
+
}
|
|
24
29
|
function envApiKey() {
|
|
25
30
|
try {
|
|
26
31
|
const env = globalThis.process?.env;
|
|
@@ -82,8 +87,9 @@ function abortableSleep(ms, signal) {
|
|
|
82
87
|
/**
|
|
83
88
|
* How long a wait (a streamed or long exec, a handle's wait()) keeps polling
|
|
84
89
|
* while its polls fail before it gives up (pollOutageMs), and a streamed or
|
|
85
|
-
* long exec stops its command
|
|
86
|
-
* or an edge that restarts
|
|
90
|
+
* long exec stops its command, and how long a start is sent again: long
|
|
91
|
+
* enough to ride out a dropped connection or an edge that restarts (about
|
|
92
|
+
* 70 s, the last time it crashed). The Python SDK's DEFAULT_POLL_OUTAGE.
|
|
87
93
|
*/
|
|
88
94
|
exports.DEFAULT_POLL_OUTAGE_MS = 120_000;
|
|
89
95
|
/** A pollOutageMs given (a number of ms ≥ 0), or undefined. */
|
|
@@ -125,6 +131,7 @@ function resolveConfig(options = {}) {
|
|
|
125
131
|
sleep: options.sleep ?? abortableSleep,
|
|
126
132
|
now: options.now ?? Date.now,
|
|
127
133
|
random: options.random ?? Math.random,
|
|
134
|
+
streamBodies: options.fetch === undefined && nodeFetch(),
|
|
128
135
|
};
|
|
129
136
|
}
|
|
130
137
|
/** Prized's own domain: its API and its edges are hosts under it. */
|
|
@@ -372,6 +379,84 @@ function backoffMs(config, attempt) {
|
|
|
372
379
|
const base = Math.min(exports.MAX_BACKOFF_MS, exports.BACKOFF_FIRST_MS * 2 ** attempt);
|
|
373
380
|
return Math.round(base / 2 + (config.random() * base) / 2);
|
|
374
381
|
}
|
|
382
|
+
/**
|
|
383
|
+
* How long a file transfer may move no bytes before the attempt is given up
|
|
384
|
+
* (sdk/retry-spec.json transferStallMs; the Python SDK's TRANSFER_STALL).
|
|
385
|
+
* The edge holds a request about 20 s at most before it reads the body (its
|
|
386
|
+
* admission queue, then waking a paused sandbox), and ends a body that brings
|
|
387
|
+
* nothing for 60 s itself: kept above that, a stalled copy is over at the
|
|
388
|
+
* edge before the next one goes to the same staged name, so the two never
|
|
389
|
+
* race. Once an upload's last byte is handed over, its answer is waited for
|
|
390
|
+
* that and a second more for every uploadAnswerBytesPerSecond it sent: what
|
|
391
|
+
* the socket's buffers, or a proxy that takes a whole body before it sends it
|
|
392
|
+
* on, still hold moves unseen, and must not read as a stall (the Python SDK's
|
|
393
|
+
* UPLOAD_ANSWER_RATE). Mutable for tests only.
|
|
394
|
+
*/
|
|
395
|
+
exports.timing = { transferStallMs: 120_000, uploadAnswerBytesPerSecond: 128 * 1024 };
|
|
396
|
+
/** A streamed upload goes out this many bytes at a time. */
|
|
397
|
+
exports.UPLOAD_CHUNK = 64 * 1024;
|
|
398
|
+
/**
|
|
399
|
+
* A body's bytes, to stream; null for one to send as it is. By shape, not
|
|
400
|
+
* instanceof: a Uint8Array or Buffer from another realm (a test runner's vm
|
|
401
|
+
* context, say) is one too.
|
|
402
|
+
*/
|
|
403
|
+
async function bodyBytes(body) {
|
|
404
|
+
if (typeof body === 'string')
|
|
405
|
+
return new TextEncoder().encode(body);
|
|
406
|
+
if (ArrayBuffer.isView(body))
|
|
407
|
+
return new Uint8Array(body.buffer, body.byteOffset, body.byteLength);
|
|
408
|
+
if (Object.prototype.toString.call(body) === '[object ArrayBuffer]')
|
|
409
|
+
return new Uint8Array(body);
|
|
410
|
+
if (typeof body.arrayBuffer === 'function')
|
|
411
|
+
return new Uint8Array(await body.arrayBuffer());
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* `bytes` as a stream fed UPLOAD_CHUNK at a time, `moved` told each time
|
|
416
|
+
* fetch asks for more, and at the end given the answer's allowance
|
|
417
|
+
* (timing.uploadAnswerBytesPerSecond).
|
|
418
|
+
*/
|
|
419
|
+
function inPieces(bytes, moved) {
|
|
420
|
+
let at = 0;
|
|
421
|
+
return new ReadableStream({
|
|
422
|
+
pull(controller) {
|
|
423
|
+
if (at >= bytes.byteLength) {
|
|
424
|
+
moved((bytes.byteLength / exports.timing.uploadAnswerBytesPerSecond) * 1000);
|
|
425
|
+
controller.close();
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
moved();
|
|
429
|
+
// A copy: a runtime may take the piece's buffer as it sends it.
|
|
430
|
+
controller.enqueue(bytes.slice(at, at + exports.UPLOAD_CHUNK));
|
|
431
|
+
at += exports.UPLOAD_CHUNK;
|
|
432
|
+
},
|
|
433
|
+
},
|
|
434
|
+
// Nothing read ahead: a pull is the socket taking the last piece.
|
|
435
|
+
{ highWaterMark: 0 });
|
|
436
|
+
}
|
|
437
|
+
/** A body read whole, `moved` told of every piece. */
|
|
438
|
+
async function readAll(response, moved) {
|
|
439
|
+
if (response.body === null)
|
|
440
|
+
return new Uint8Array(0);
|
|
441
|
+
const reader = response.body.getReader();
|
|
442
|
+
const parts = [];
|
|
443
|
+
let size = 0;
|
|
444
|
+
for (;;) {
|
|
445
|
+
const { done, value } = await reader.read();
|
|
446
|
+
if (done)
|
|
447
|
+
break;
|
|
448
|
+
moved();
|
|
449
|
+
parts.push(value);
|
|
450
|
+
size += value.byteLength;
|
|
451
|
+
}
|
|
452
|
+
const whole = new Uint8Array(size);
|
|
453
|
+
let at = 0;
|
|
454
|
+
for (const part of parts) {
|
|
455
|
+
whole.set(part, at);
|
|
456
|
+
at += part.byteLength;
|
|
457
|
+
}
|
|
458
|
+
return whole;
|
|
459
|
+
}
|
|
375
460
|
/**
|
|
376
461
|
* One fetch, body included, under one timeout and the caller's signal. A
|
|
377
462
|
* body that stalls or breaks after the headers fails the attempt like a
|
|
@@ -380,10 +465,24 @@ function backoffMs(config, attempt) {
|
|
|
380
465
|
async function attemptOnce(config, spec, url, timeoutMs) {
|
|
381
466
|
const controller = new AbortController();
|
|
382
467
|
let timedOut = false;
|
|
468
|
+
let stalled = false;
|
|
383
469
|
const timer = setTimeout(() => {
|
|
384
470
|
timedOut = true;
|
|
385
471
|
controller.abort();
|
|
386
472
|
}, timeoutMs);
|
|
473
|
+
// The stall bound starts over whenever bytes move (stallMs), until the attempt is over.
|
|
474
|
+
let stall;
|
|
475
|
+
let over = false;
|
|
476
|
+
const moved = (extraMs = 0) => {
|
|
477
|
+
if (spec.stallMs === undefined || over)
|
|
478
|
+
return;
|
|
479
|
+
clearTimeout(stall);
|
|
480
|
+
stall = setTimeout(() => {
|
|
481
|
+
stalled = true;
|
|
482
|
+
timedOut = true;
|
|
483
|
+
controller.abort();
|
|
484
|
+
}, spec.stallMs + extraMs);
|
|
485
|
+
};
|
|
387
486
|
const onAbort = () => controller.abort();
|
|
388
487
|
spec.signal?.addEventListener('abort', onAbort, { once: true });
|
|
389
488
|
const headers = {
|
|
@@ -393,30 +492,52 @@ async function attemptOnce(config, spec, url, timeoutMs) {
|
|
|
393
492
|
...spec.headers,
|
|
394
493
|
};
|
|
395
494
|
let body;
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
}
|
|
400
|
-
else if (spec.body !== undefined) {
|
|
401
|
-
headers['content-type'] = spec.contentType ?? 'application/octet-stream';
|
|
402
|
-
body = spec.body;
|
|
403
|
-
}
|
|
495
|
+
// Watched from the start unless an upload goes out unwatched (Config.streamBodies).
|
|
496
|
+
let watched = spec.stallMs !== undefined && spec.body === undefined;
|
|
497
|
+
let streamed = false;
|
|
404
498
|
let status = null;
|
|
405
499
|
try {
|
|
500
|
+
if (spec.json !== undefined) {
|
|
501
|
+
headers['content-type'] = 'application/json';
|
|
502
|
+
body = JSON.stringify(spec.json);
|
|
503
|
+
}
|
|
504
|
+
else if (spec.body !== undefined) {
|
|
505
|
+
headers['content-type'] = spec.contentType ?? 'application/octet-stream';
|
|
506
|
+
body = spec.body;
|
|
507
|
+
const bytes = spec.stallMs !== undefined && config.streamBodies ? await bodyBytes(spec.body) : null;
|
|
508
|
+
if (bytes !== null) {
|
|
509
|
+
headers['content-length'] = String(bytes.byteLength);
|
|
510
|
+
body = inPieces(bytes, moved);
|
|
511
|
+
streamed = true;
|
|
512
|
+
watched = true;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
if (watched)
|
|
516
|
+
moved();
|
|
406
517
|
const response = await config.fetch(url, {
|
|
407
518
|
method: spec.method,
|
|
408
519
|
headers,
|
|
409
520
|
body: body,
|
|
521
|
+
// A streamed body (Node's fetch asks for it): sent before the answer is read.
|
|
522
|
+
...(streamed ? { duplex: 'half' } : {}),
|
|
410
523
|
// Never follow a redirect with the key attached.
|
|
411
524
|
redirect: 'manual',
|
|
412
525
|
signal: controller.signal,
|
|
413
526
|
});
|
|
414
527
|
status = response.status;
|
|
528
|
+
if (spec.stallMs !== undefined)
|
|
529
|
+
moved();
|
|
415
530
|
const ok = response.status >= 200 && response.status < 300;
|
|
416
531
|
if (response.status >= 300 && response.status < 400) {
|
|
417
532
|
await response.body?.cancel().catch(() => undefined);
|
|
418
533
|
return { answer: { status: response.status, headers: response.headers, text: '', bytes: null } };
|
|
419
534
|
}
|
|
535
|
+
if (spec.stallMs !== undefined) {
|
|
536
|
+
const whole = await readAll(response, moved);
|
|
537
|
+
if (ok && spec.read === 'bytes')
|
|
538
|
+
return { answer: { status: response.status, headers: response.headers, text: '', bytes: whole } };
|
|
539
|
+
return { answer: { status: response.status, headers: response.headers, text: new TextDecoder('utf-8').decode(whole), bytes: null } };
|
|
540
|
+
}
|
|
420
541
|
if (ok && spec.read === 'bytes') {
|
|
421
542
|
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
422
543
|
return { answer: { status: response.status, headers: response.headers, text: '', bytes } };
|
|
@@ -424,10 +545,12 @@ async function attemptOnce(config, spec, url, timeoutMs) {
|
|
|
424
545
|
return { answer: { status: response.status, headers: response.headers, text: await response.text(), bytes: null } };
|
|
425
546
|
}
|
|
426
547
|
catch (error) {
|
|
427
|
-
return { error, timedOut, status };
|
|
548
|
+
return { error, timedOut, stalled, status };
|
|
428
549
|
}
|
|
429
550
|
finally {
|
|
551
|
+
over = true;
|
|
430
552
|
clearTimeout(timer);
|
|
553
|
+
clearTimeout(stall);
|
|
431
554
|
spec.signal?.removeEventListener('abort', onAbort);
|
|
432
555
|
}
|
|
433
556
|
}
|
|
@@ -490,8 +613,8 @@ async function request(config, spec) {
|
|
|
490
613
|
const timeoutMs = spec.timeoutMs ?? config.requestTimeoutMs;
|
|
491
614
|
const sandboxId = spec.sandboxId ?? null;
|
|
492
615
|
const effect = spec.effect ?? (spec.retry === 'exec' ? (0, outcome_js_1.commandEffect)() : undefined);
|
|
493
|
-
// Whether an attempt that was sent again may have reached the sandbox.
|
|
494
|
-
let acted = false;
|
|
616
|
+
// Whether an attempt that was sent again (or an earlier copy) may have reached the sandbox.
|
|
617
|
+
let acted = spec.acted ?? false;
|
|
495
618
|
// Names the last attempt's failure, if any, so a call cut short by its
|
|
496
619
|
// deadline still says what it was waiting out (a 503, a rate limit).
|
|
497
620
|
const pastDeadline = (last) => {
|
|
@@ -500,9 +623,10 @@ async function request(config, spec) {
|
|
|
500
623
|
let error = new errors_js_1.PrizedError('timeout', `The time allowed for this call ran out.${tail}`, { sandboxId, cause: last });
|
|
501
624
|
// A command is sent again only when it provably never arrived, so every
|
|
502
625
|
// attempt of one cut short this way never reached the sandbox. A staged
|
|
503
|
-
// upload's copies may have, unless each provably never arrived
|
|
504
|
-
|
|
505
|
-
|
|
626
|
+
// upload's copies may have, unless each provably never arrived, and so
|
|
627
|
+
// may a keyed start's (the one `safe` request that names an effect).
|
|
628
|
+
if (effect !== undefined)
|
|
629
|
+
error = (0, outcome_js_1.withOutcome)(error, effect, spec.retry !== 'exec' && acted ? 'maybe' : 'never');
|
|
506
630
|
cutShort.add(error);
|
|
507
631
|
return error;
|
|
508
632
|
};
|
|
@@ -540,7 +664,10 @@ async function request(config, spec) {
|
|
|
540
664
|
}
|
|
541
665
|
const unsentAttempt = !outcome.timedOut && neverSent(outcome.error);
|
|
542
666
|
failure = failed(outcome.timedOut
|
|
543
|
-
? new errors_js_1.PrizedError('timeout', `No answer within ${attemptMs} ms`, {
|
|
667
|
+
? new errors_js_1.PrizedError('timeout', outcome.stalled ? `No bytes moved for ${spec.stallMs} ms` : `No answer within ${attemptMs} ms`, {
|
|
668
|
+
sandboxId,
|
|
669
|
+
cause: outcome.error,
|
|
670
|
+
})
|
|
544
671
|
: unsentAttempt
|
|
545
672
|
? new errors_js_1.PrizedError('network_error', null, { sandboxId, cause: outcome.error })
|
|
546
673
|
: new errors_js_1.PrizedError('network_error', lostMessage(spec.method, url, outcome.error), { sandboxId, cause: outcome.error }));
|
|
@@ -565,7 +692,8 @@ async function request(config, spec) {
|
|
|
565
692
|
}
|
|
566
693
|
}
|
|
567
694
|
if (answer.status >= 300 && answer.status < 400) {
|
|
568
|
-
|
|
695
|
+
// A refusal; worded only when an earlier copy of a keyed start may have arrived.
|
|
696
|
+
throw raised(failed(new errors_js_1.PrizedError('unexpected_redirect', null, { status: answer.status, sandboxId })), null, acted, effect);
|
|
569
697
|
}
|
|
570
698
|
const retryAfter = (0, errors_js_1.parseRetryAfter)(answer.headers.get('retry-after'), config.now());
|
|
571
699
|
failure = failed((0, errors_js_1.errorFromResponse)(answer.status, answer.text, retryAfter, sandboxId, urlPath(url)));
|
package/dist/cjs/outcome.d.ts
CHANGED
|
@@ -49,6 +49,15 @@ export declare function withOutcome(error: PrizedError, effect: Effect, outcome:
|
|
|
49
49
|
* connection closes. TypeScript only: the Python SDK takes no signal.
|
|
50
50
|
*/
|
|
51
51
|
export declare function withAbort(error: PrizedError, effect: Effect, outcome: 'never' | 'maybe' | 'did'): PrizedError;
|
|
52
|
+
/**
|
|
53
|
+
* What a start that gave up throws when the failure it gives up with
|
|
54
|
+
* (`failure`, Outage's pick) is not the last copy's own: an earlier copy's,
|
|
55
|
+
* worded before a later copy may have reached the sandbox, or what a copy
|
|
56
|
+
* cut short by the window met, not yet worded. Worded for the start: as one
|
|
57
|
+
* that may have started when any copy may have reached the sandbox
|
|
58
|
+
* (`anyActed`), else as one that never did. The Python SDK's start_gave_up.
|
|
59
|
+
*/
|
|
60
|
+
export declare function startGaveUp(failure: PrizedError, anyActed: boolean): PrizedError;
|
|
52
61
|
/**
|
|
53
62
|
* A background start that may have started (`error`, from withOutcome or
|
|
54
63
|
* withAbort) and that the SDK then stopped by its run id (`stopped`: the
|