@molecule/api-code-sandbox-flyio 1.2.8 → 1.2.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/api.d.ts CHANGED
@@ -125,7 +125,12 @@ export declare class FlyApiClient {
125
125
  private readonly baseUrl;
126
126
  private readonly timeoutMs;
127
127
  private readonly fetchImpl;
128
- private readonly sleep;
128
+ /**
129
+ * Injectable delay, shared with the provider: a retry that a CALLER drives
130
+ * (the provider's exec-precondition backoff) waits on the same injected clock
131
+ * the client's own retries use, so tests collapse every wait in one place.
132
+ */
133
+ readonly sleep: (ms: number) => Promise<void>;
129
134
  /**
130
135
  * Creates a Fly Machines API client.
131
136
  * @param options - Token resolver, base URL, and injectable fetch/sleep.
package/dist/api.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAgBH;;;;GAIG;AACH,qBAAa,WAAY,SAAQ,KAAK;IACpC,kFAAkF;IAClF,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,sEAAsE;IACtE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,yCAAyC;IACzC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,oDAAoD;IACpD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IAErB;;;;;;;OAOG;gBAED,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE;CAgBhC;AAED,4CAA4C;AAC5C,MAAM,WAAW,iBAAiB;IAChC,sCAAsC;IACtC,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,0DAA0D;IAC1D,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,8EAA8E;IAC9E,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,sFAAsF;IACtF,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAA;IACjB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;CACzB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAEzD;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAQhF;AAED;;;;;;;;;GASG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAoBjE;AAED,oDAAoD;AACpD,MAAM,WAAW,mBAAmB;IAClC,gFAAgF;IAChF,KAAK,EAAE,MAAM,MAAM,GAAG,SAAS,CAAA;IAC/B,gCAAgC;IAChC,OAAO,EAAE,MAAM,CAAA;IACf,wCAAwC;IACxC,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,mEAAmE;IACnE,SAAS,CAAC,EAAE,OAAO,KAAK,CAAA;IACxB,6DAA6D;IAC7D,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;CACtC;AAgDD,kFAAkF;AAClF,wBAAgB,qBAAqB,IAAI,IAAI,CAG5C;AAED;;;;GAIG;AACH,qBAAa,YAAY;IACvB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA0B;IAChD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAQ;IAChC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAQ;IAClC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAc;IACxC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA+B;IAErD;;;OAGG;gBACS,OAAO,EAAE,mBAAmB;IASxC;;;;;;;;;;OAUG;IACG,OAAO,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,iBAAsB,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC;CAiFnF"}
1
+ {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAgBH;;;;GAIG;AACH,qBAAa,WAAY,SAAQ,KAAK;IACpC,kFAAkF;IAClF,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,sEAAsE;IACtE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,yCAAyC;IACzC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,oDAAoD;IACpD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IAErB;;;;;;;OAOG;gBAED,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE;CAgBhC;AAED,4CAA4C;AAC5C,MAAM,WAAW,iBAAiB;IAChC,sCAAsC;IACtC,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,0DAA0D;IAC1D,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,8EAA8E;IAC9E,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,sFAAsF;IACtF,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAA;IACjB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;CACzB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAEzD;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAQhF;AAED;;;;;;;;;GASG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAoBjE;AAED,oDAAoD;AACpD,MAAM,WAAW,mBAAmB;IAClC,gFAAgF;IAChF,KAAK,EAAE,MAAM,MAAM,GAAG,SAAS,CAAA;IAC/B,gCAAgC;IAChC,OAAO,EAAE,MAAM,CAAA;IACf,wCAAwC;IACxC,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,mEAAmE;IACnE,SAAS,CAAC,EAAE,OAAO,KAAK,CAAA;IACxB,6DAA6D;IAC7D,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;CACtC;AAgDD,kFAAkF;AAClF,wBAAgB,qBAAqB,IAAI,IAAI,CAG5C;AAED;;;;GAIG;AACH,qBAAa,YAAY;IACvB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA0B;IAChD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAQ;IAChC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAQ;IAClC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAc;IACxC;;;;OAIG;IACH,QAAQ,CAAC,KAAK,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;IAE7C;;;OAGG;gBACS,OAAO,EAAE,mBAAmB;IASxC;;;;;;;;;;OAUG;IACG,OAAO,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,iBAAsB,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC;CAiFnF"}
package/dist/api.js CHANGED
@@ -176,6 +176,11 @@ export class FlyApiClient {
176
176
  baseUrl;
177
177
  timeoutMs;
178
178
  fetchImpl;
179
+ /**
180
+ * Injectable delay, shared with the provider: a retry that a CALLER drives
181
+ * (the provider's exec-precondition backoff) waits on the same injected clock
182
+ * the client's own retries use, so tests collapse every wait in one place.
183
+ */
179
184
  sleep;
180
185
  /**
181
186
  * Creates a Fly Machines API client.
package/dist/index.d.ts CHANGED
@@ -315,12 +315,26 @@
315
315
  * short-term burst limit up to 3 req/s, per action", scoped "per-action,
316
316
  * per-machine … That might be Machine ID or App ID, depending on the type of
317
317
  * request", with Get Machine at 5 req/s and app deletions capped at 100 per
318
- * minute. `429` and `5xx` are retried with `Retry-After`-aware backoff; other
319
- * `4xx` responses are real answers and are never retried. The consequence to
320
- * plan for is that EVERY file operation is an exec API call, so one sandbox's
321
- * reads and writes serialize at roughly one per second and a multi-megabyte
322
- * `writeFile` (chunked at ~45 KB decoded) takes tens of seconds. An agent that
323
- * edits files in a tight loop will feel this; batch where you can.
318
+ * minute. `429` and `5xx` are retried with `Retry-After`-aware backoff, plus the
319
+ * `404` an app-propagation lag answers the Machine create with; every other
320
+ * `4xx` is a real answer and is never retried. The consequence to plan for is
321
+ * that EVERY file operation is an exec API call, so one sandbox's reads and
322
+ * writes serialize at roughly one per second and a multi-megabyte `writeFile`
323
+ * (chunked at ~45 KB decoded) takes tens of seconds. An agent that edits files
324
+ * in a tight loop will feel this; batch where you can.
325
+ *
326
+ * **A refused exec that dropped the connection is retried; a refused exec that
327
+ * names a Machine state is not.** Fly answers `412 failed_precondition: exec
328
+ * request failed: EOF` when the request reached a `started` Machine's exec agent
329
+ * and the connection died before the command ran — a Machine seconds past boot,
330
+ * or Fly's own exec burst. `exec` retries that one at the transport (up to four
331
+ * attempts, ~11 s of backoff, each preceded by a Machine-state read and a wait
332
+ * for `started` when it has not got there), because every caller's alternative
333
+ * is to throw away a whole boot or deploy and start over. The OTHER `412`,
334
+ * `failed_precondition: machine not running`, is a real state and is not
335
+ * repeated: `start()`/`wake()` wait it out before the first exec. A 412 that is
336
+ * not retried comes back with the Machine's current state appended to its
337
+ * message, and a 412 the retries could not clear carries the state it ended in.
324
338
  *
325
339
  * **`create()` launches the Machine, and `start()`/`wake()` WAIT for it.** Unlike
326
340
  * Docker's create-then-start, the Machines API starts a Machine as part of
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqVG;AAEH,cAAc,UAAU,CAAA;AACxB,cAAc,oBAAoB,CAAA;AAClC,cAAc,aAAa,CAAA;AAC3B,cAAc,WAAW,CAAA;AACzB,cAAc,cAAc,CAAA;AAC5B,cAAc,UAAU,CAAA;AACxB,cAAc,eAAe,CAAA;AAC7B,cAAc,cAAc,CAAA;AAC5B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,YAAY,CAAA;AAC1B,cAAc,gBAAgB,CAAA;AAE9B,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAA;AAMjE;;GAEG;AACH,eAAO,MAAM,QAAQ,EAAE,eAUrB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmWG;AAEH,cAAc,UAAU,CAAA;AACxB,cAAc,oBAAoB,CAAA;AAClC,cAAc,aAAa,CAAA;AAC3B,cAAc,WAAW,CAAA;AACzB,cAAc,cAAc,CAAA;AAC5B,cAAc,UAAU,CAAA;AACxB,cAAc,eAAe,CAAA;AAC7B,cAAc,cAAc,CAAA;AAC5B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,YAAY,CAAA;AAC1B,cAAc,gBAAgB,CAAA;AAE9B,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAA;AAMjE;;GAEG;AACH,eAAO,MAAM,QAAQ,EAAE,eAUrB,CAAA"}
package/dist/index.js CHANGED
@@ -315,12 +315,26 @@
315
315
  * short-term burst limit up to 3 req/s, per action", scoped "per-action,
316
316
  * per-machine … That might be Machine ID or App ID, depending on the type of
317
317
  * request", with Get Machine at 5 req/s and app deletions capped at 100 per
318
- * minute. `429` and `5xx` are retried with `Retry-After`-aware backoff; other
319
- * `4xx` responses are real answers and are never retried. The consequence to
320
- * plan for is that EVERY file operation is an exec API call, so one sandbox's
321
- * reads and writes serialize at roughly one per second and a multi-megabyte
322
- * `writeFile` (chunked at ~45 KB decoded) takes tens of seconds. An agent that
323
- * edits files in a tight loop will feel this; batch where you can.
318
+ * minute. `429` and `5xx` are retried with `Retry-After`-aware backoff, plus the
319
+ * `404` an app-propagation lag answers the Machine create with; every other
320
+ * `4xx` is a real answer and is never retried. The consequence to plan for is
321
+ * that EVERY file operation is an exec API call, so one sandbox's reads and
322
+ * writes serialize at roughly one per second and a multi-megabyte `writeFile`
323
+ * (chunked at ~45 KB decoded) takes tens of seconds. An agent that edits files
324
+ * in a tight loop will feel this; batch where you can.
325
+ *
326
+ * **A refused exec that dropped the connection is retried; a refused exec that
327
+ * names a Machine state is not.** Fly answers `412 failed_precondition: exec
328
+ * request failed: EOF` when the request reached a `started` Machine's exec agent
329
+ * and the connection died before the command ran — a Machine seconds past boot,
330
+ * or Fly's own exec burst. `exec` retries that one at the transport (up to four
331
+ * attempts, ~11 s of backoff, each preceded by a Machine-state read and a wait
332
+ * for `started` when it has not got there), because every caller's alternative
333
+ * is to throw away a whole boot or deploy and start over. The OTHER `412`,
334
+ * `failed_precondition: machine not running`, is a real state and is not
335
+ * repeated: `start()`/`wake()` wait it out before the first exec. A 412 that is
336
+ * not retried comes back with the Machine's current state appended to its
337
+ * message, and a 412 the retries could not clear carries the state it ended in.
324
338
  *
325
339
  * **`create()` launches the Machine, and `start()`/`wake()` WAIT for it.** Unlike
326
340
  * Docker's create-then-start, the Machines API starts a Machine as part of
@@ -10,6 +10,50 @@ import type { SandboxProvider } from '@molecule/api-code-sandbox';
10
10
  import { FlyApiClient } from './api.js';
11
11
  import { type ObjectStore } from './storage.js';
12
12
  import type { FlyioConfig } from './types.js';
13
+ /**
14
+ * Whether a failed exec is Fly's TRANSIENT precondition — one a repeat (after a
15
+ * short wait) can clear, rather than a Machine state the exec has to wait out.
16
+ *
17
+ * Fly answers the exec endpoint with 412 in two different situations, and only
18
+ * the BODY and the Machine's own reported state separate them:
19
+ *
20
+ * 1. `failed_precondition: exec request failed: EOF` — the request reached the
21
+ * Machine's exec agent and the connection dropped before the command ran.
22
+ * Always transient: the Machine is `started` and its agent recovers in
23
+ * seconds.
24
+ * 2. `failed_precondition: machine not running` — transient ONLY while Fly's
25
+ * own Machines API reports the Machine `started`, `starting` or `replacing`
26
+ * (`machineState`). The exec endpoint lags the state Fly reports: a Machine
27
+ * whose config or image was just updated, or that is seconds past boot,
28
+ * reads `started` in `GET /machines/{id}` while its exec endpoint still
29
+ * refuses requests. Against a Machine Fly reports `stopped`/`suspended`/
30
+ * `destroyed` the same body is the REAL precondition — the Machine is not
31
+ * running, and {@link FlyioSandboxProvider.startMachine} is what answers it;
32
+ * repeating the exec would only burn the caller's budget.
33
+ *
34
+ * @param error - The failure an exec attempt raised.
35
+ * @param machineState - The state `GET /machines/{id}` reports for the Machine,
36
+ * or `null`/`undefined` when it could not be read (which classifies case 2 as
37
+ * NOT transient — an unknown state is never assumed recoverable).
38
+ * @returns `true` when repeating the exec is the right recovery.
39
+ */
40
+ export declare function isTransientExecPrecondition(error: unknown, machineState?: string | null): boolean;
41
+ /**
42
+ * Whether a state Fly reports for a Machine means the Machine is up, or on its
43
+ * way up — the states in which an exec that was refused for `machine not
44
+ * running` is the exec endpoint lagging the state, not the truth.
45
+ *
46
+ * A deliberate allow-list rather than "anything but stopped/suspended": the
47
+ * three states here are the ones a refused exec was observed to recover from,
48
+ * and every other state — `stopped`, `suspended`, `destroyed`, `failed`,
49
+ * `created`, unreadable — keeps today's immediate throw. Widening it without
50
+ * that evidence would turn a real precondition into a retry budget spent
51
+ * against a Machine that is not coming up.
52
+ *
53
+ * @param state - The Machine state from `GET /machines/{id}`, if readable.
54
+ * @returns `true` when waiting and repeating the exec can clear the refusal.
55
+ */
56
+ export declare function isMachineWarmingUp(state: string | null | undefined): boolean;
13
57
  /**
14
58
  * Creates a Fly.io Machines sandbox provider.
15
59
  * @param config - Optional Fly configuration: API token/URL, org slug, app
@@ -1 +1 @@
1
- {"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAMH,OAAO,KAAK,EAWV,eAAe,EAGhB,MAAM,4BAA4B,CAAA;AAGnC,OAAO,EAAE,YAAY,EAAgC,MAAM,UAAU,CAAA;AAwBrE,OAAO,EAAmD,KAAK,WAAW,EAAE,MAAM,cAAc,CAAA;AAEhG,OAAO,KAAK,EAIV,WAAW,EAUZ,MAAM,YAAY,CAAA;AAsgFnB;;;;;;;;;GASG;AACH,wBAAgB,cAAc,CAC5B,MAAM,CAAC,EAAE,WAAW,EACpB,MAAM,CAAC,EAAE,YAAY,EACrB,KAAK,CAAC,EAAE,WAAW,GAAG,IAAI,GACzB,eAAe,CAEjB"}
1
+ {"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAMH,OAAO,KAAK,EAWV,eAAe,EAGhB,MAAM,4BAA4B,CAAA;AAGnC,OAAO,EAAE,YAAY,EAAgC,MAAM,UAAU,CAAA;AAwBrE,OAAO,EAAmD,KAAK,WAAW,EAAE,MAAM,cAAc,CAAA;AAEhG,OAAO,KAAK,EAIV,WAAW,EAUZ,MAAM,YAAY,CAAA;AAkBnB;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,2BAA2B,CAAC,KAAK,EAAE,OAAO,EAAE,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAIjG;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAE5E;AAksFD;;;;;;;;;GASG;AACH,wBAAgB,cAAc,CAC5B,MAAM,CAAC,EAAE,WAAW,EACpB,MAAM,CAAC,EAAE,YAAY,EACrB,KAAK,CAAC,EAAE,WAAW,GAAG,IAAI,GACzB,eAAe,CAEjB"}
package/dist/provider.js CHANGED
@@ -31,6 +31,58 @@ function describeDestroyFailure(step, id, error) {
31
31
  const reason = error instanceof Error ? error.message : String(error);
32
32
  return `Fly sandbox destroy failed at the ${step} for ${id} (${status}): ${reason}`;
33
33
  }
34
+ /**
35
+ * Whether a failed exec is Fly's TRANSIENT precondition — one a repeat (after a
36
+ * short wait) can clear, rather than a Machine state the exec has to wait out.
37
+ *
38
+ * Fly answers the exec endpoint with 412 in two different situations, and only
39
+ * the BODY and the Machine's own reported state separate them:
40
+ *
41
+ * 1. `failed_precondition: exec request failed: EOF` — the request reached the
42
+ * Machine's exec agent and the connection dropped before the command ran.
43
+ * Always transient: the Machine is `started` and its agent recovers in
44
+ * seconds.
45
+ * 2. `failed_precondition: machine not running` — transient ONLY while Fly's
46
+ * own Machines API reports the Machine `started`, `starting` or `replacing`
47
+ * (`machineState`). The exec endpoint lags the state Fly reports: a Machine
48
+ * whose config or image was just updated, or that is seconds past boot,
49
+ * reads `started` in `GET /machines/{id}` while its exec endpoint still
50
+ * refuses requests. Against a Machine Fly reports `stopped`/`suspended`/
51
+ * `destroyed` the same body is the REAL precondition — the Machine is not
52
+ * running, and {@link FlyioSandboxProvider.startMachine} is what answers it;
53
+ * repeating the exec would only burn the caller's budget.
54
+ *
55
+ * @param error - The failure an exec attempt raised.
56
+ * @param machineState - The state `GET /machines/{id}` reports for the Machine,
57
+ * or `null`/`undefined` when it could not be read (which classifies case 2 as
58
+ * NOT transient — an unknown state is never assumed recoverable).
59
+ * @returns `true` when repeating the exec is the right recovery.
60
+ */
61
+ export function isTransientExecPrecondition(error, machineState) {
62
+ if (!(error instanceof FlyApiError) || error.status !== 412)
63
+ return false;
64
+ if (EXEC_BODY_DROPPED_CONNECTION.test(error.body))
65
+ return true;
66
+ return EXEC_BODY_MACHINE_NOT_RUNNING.test(error.body) && isMachineWarmingUp(machineState);
67
+ }
68
+ /**
69
+ * Whether a state Fly reports for a Machine means the Machine is up, or on its
70
+ * way up — the states in which an exec that was refused for `machine not
71
+ * running` is the exec endpoint lagging the state, not the truth.
72
+ *
73
+ * A deliberate allow-list rather than "anything but stopped/suspended": the
74
+ * three states here are the ones a refused exec was observed to recover from,
75
+ * and every other state — `stopped`, `suspended`, `destroyed`, `failed`,
76
+ * `created`, unreadable — keeps today's immediate throw. Widening it without
77
+ * that evidence would turn a real precondition into a retry budget spent
78
+ * against a Machine that is not coming up.
79
+ *
80
+ * @param state - The Machine state from `GET /machines/{id}`, if readable.
81
+ * @returns `true` when waiting and repeating the exec can clear the refusal.
82
+ */
83
+ export function isMachineWarmingUp(state) {
84
+ return state === 'started' || state === 'starting' || state === 'replacing';
85
+ }
34
86
  /**
35
87
  * Default sandbox image. Fly pulls images itself, so this MUST be a reference
36
88
  * Fly can resolve — a tag in the org's `registry.fly.io` repository or a public
@@ -158,6 +210,54 @@ const MACHINE_CREATE_ATTEMPTS = 6;
158
210
  * default retryable set; neither retry can double-create.
159
211
  */
160
212
  const MACHINE_CREATE_RETRY_STATUSES = [404, 412];
213
+ /**
214
+ * What Fly answers when it reached the exec endpoint but the connection to the
215
+ * Machine's exec agent died before the command ran: `412 {"error":
216
+ * "failed_precondition: exec request failed: EOF"}`. The Machine is `started`;
217
+ * the agent inside it dropped the request (a Machine seconds past boot, or
218
+ * Fly's own exec burst).
219
+ */
220
+ const EXEC_BODY_DROPPED_CONNECTION = /exec request failed/i;
221
+ /**
222
+ * The OTHER 412 the exec endpoint answers — `{"error":"failed_precondition:
223
+ * machine not running"}`. Whether that is the real precondition or the exec
224
+ * endpoint lagging a Machine that is up cannot be read off the body, so it is
225
+ * settled by the state Fly reports for the Machine at that moment
226
+ * ({@link isMachineWarmingUp}).
227
+ */
228
+ const EXEC_BODY_MACHINE_NOT_RUNNING = /machine not running/i;
229
+ /**
230
+ * Attempts for one exec when Fly answers a transient 412 — either body of
231
+ * {@link isTransientExecPrecondition}. Four attempts over the three backoffs
232
+ * below is under 12 s of waiting, against the ~75 s each fresh production
233
+ * deploy costs when this is left to a caller: X1 run flash-m85 (2026-10-16)
234
+ * lost three consecutive deploys, and the run, to the dropped connection;
235
+ * flash-m94 (2026-10-16) lost 65 s and a whole redeploy to the same budget not
236
+ * covering `machine not running` on a Machine Fly reported `started`.
237
+ */
238
+ const EXEC_PRECONDITION_ATTEMPTS = 4;
239
+ /**
240
+ * Backoff before each exec retry (ms), indexed by the failed attempt number.
241
+ * Short on purpose: the Machine is up, and the agent recovers in seconds —
242
+ * nothing here is waiting on a boot.
243
+ */
244
+ const EXEC_PRECONDITION_BACKOFF_MS = [1_000, 3_000, 7_000];
245
+ /**
246
+ * Wall-clock ceiling on {@link EXEC_PRECONDITION_ATTEMPTS}, so a Machine whose
247
+ * exec agent keeps dropping requests cannot hold a caller for the whole budget
248
+ * while the rest of the backoff ladder runs. Measured from the first attempt.
249
+ */
250
+ const EXEC_PRECONDITION_BUDGET_MS = 60_000;
251
+ /**
252
+ * The backoff before an exec retry, clamped to the last rung so an attempt
253
+ * count raised past the ladder's length cannot read off the end of it.
254
+ * @param attempt - The 1-based number of the attempt that just failed.
255
+ * @returns The delay in milliseconds.
256
+ */
257
+ function execPreconditionBackoffMs(attempt) {
258
+ const last = EXEC_PRECONDITION_BACKOFF_MS.length - 1;
259
+ return EXEC_PRECONDITION_BACKOFF_MS[Math.min(Math.max(attempt - 1, 0), last)];
260
+ }
161
261
  /**
162
262
  * Base64 chunk size for {@link Sandbox.importFiles}'s temp-file write. Bounded by
163
263
  * the same Fly exec payload limit as `WRITE_CHUNK_BASE64` (~15 KB, silently
@@ -1772,28 +1872,166 @@ class FlyioSandboxProvider {
1772
1872
  // ---------------------------------------------------------------------------
1773
1873
  // Sandbox
1774
1874
  // ---------------------------------------------------------------------------
1875
+ /**
1876
+ * Reads the state Fly reports for a Machine, or `null` when it cannot be read.
1877
+ *
1878
+ * Best-effort on purpose: this runs both on the failure path and just before a
1879
+ * retry, and a failed diagnostic read must not replace the failure it is
1880
+ * describing. `null` is meaningful — it never classifies a refusal as
1881
+ * recoverable (see {@link isTransientExecPrecondition}).
1882
+ *
1883
+ * @param app - The Fly app name.
1884
+ * @param machineId - The Fly Machine id.
1885
+ * @returns The Machine's state, or `null` when the read failed or 404'd.
1886
+ */
1887
+ async readMachineState(app, machineId) {
1888
+ try {
1889
+ const machine = await this.client.request(`/apps/${app}/machines/${machineId}`, {
1890
+ nullOn: [404],
1891
+ });
1892
+ return machine?.state ?? null;
1893
+ }
1894
+ catch (error) {
1895
+ logger.debug('Could not read the Machine state while an exec was refused', {
1896
+ app,
1897
+ machineId,
1898
+ error,
1899
+ });
1900
+ return null;
1901
+ }
1902
+ }
1775
1903
  /**
1776
1904
  * Issues one Fly exec call against a Machine.
1905
+ *
1906
+ * A transient 412 (see {@link isTransientExecPrecondition}) is retried here, at
1907
+ * the transport, because everywhere it surfaces — a boot, the production
1908
+ * artifact copy, the boot script, a health probe — the caller's alternative is
1909
+ * to throw the whole operation away and start over, which costs tens of seconds
1910
+ * or minutes. Before the retry the Machine's state is re-read (and waited on
1911
+ * when it is not yet `started`), so a Machine that really is still coming up is
1912
+ * not hammered.
1913
+ *
1914
+ * A 412 that is NOT that condition is never retried, and its message carries
1915
+ * the Machine's current state — the caller is told which state the exec was
1916
+ * refused in rather than a bare `failed_precondition`.
1917
+ *
1777
1918
  * @param app - The Fly app name.
1778
1919
  * @param machineId - The Fly Machine id.
1779
1920
  * @returns A transport callback for {@link execCommand}.
1780
1921
  */
1781
1922
  rawExec(app, machineId) {
1782
1923
  return async (command, timeoutSeconds) => {
1783
- const response = await this.client.request(`/apps/${app}/machines/${machineId}/exec`, {
1784
- method: 'POST',
1785
- body: { command, timeout: timeoutSeconds },
1786
- // The API's own timeout bounds the command; allow the HTTP request a
1787
- // little longer so the transport never fires first.
1788
- timeoutMs: (timeoutSeconds + 15) * 1000,
1789
- // A 404 on a Machine we created and are actively driving (e.g. the
1790
- // exec burst importFiles issues) is Fly API inconsistency under load,
1791
- // not a real absence — retry it alongside the default 429/5xx.
1792
- retryStatuses: [404],
1793
- });
1794
- return response ?? {};
1924
+ const deadline = Date.now() + EXEC_PRECONDITION_BUDGET_MS;
1925
+ for (let attempt = 1;; attempt++) {
1926
+ try {
1927
+ const response = await this.client.request(`/apps/${app}/machines/${machineId}/exec`, {
1928
+ method: 'POST',
1929
+ body: { command, timeout: timeoutSeconds },
1930
+ // The API's own timeout bounds the command; allow the HTTP request a
1931
+ // little longer so the transport never fires first.
1932
+ timeoutMs: (timeoutSeconds + 15) * 1000,
1933
+ // A 404 on a Machine we created and are actively driving (e.g. the
1934
+ // exec burst importFiles issues) is Fly API inconsistency under load,
1935
+ // not a real absence — retry it alongside the default 429/5xx.
1936
+ retryStatuses: [404],
1937
+ });
1938
+ return response ?? {};
1939
+ }
1940
+ catch (error) {
1941
+ // Which 412 this is cannot be read off the body alone: `machine not
1942
+ // running` is transient against a Machine Fly reports `started` and the
1943
+ // real precondition against one it reports `stopped`. So the state is
1944
+ // read here, once, and the same read decides the classification, the
1945
+ // wait below, and the state the error finally names.
1946
+ const state = error instanceof FlyApiError && error.status === 412
1947
+ ? await this.readMachineState(app, machineId)
1948
+ : null;
1949
+ if (!isTransientExecPrecondition(error, state)) {
1950
+ throw await this.withMachineState(error, app, machineId, state);
1951
+ }
1952
+ if (attempt >= EXEC_PRECONDITION_ATTEMPTS || Date.now() >= deadline) {
1953
+ logger.warn('Fly kept refusing the exec with a transient precondition', {
1954
+ app,
1955
+ machineId,
1956
+ attempt,
1957
+ state: state ?? 'unreadable',
1958
+ });
1959
+ throw await this.withMachineState(error, app, machineId, state);
1960
+ }
1961
+ logger.warn('Fly refused the exec with a transient precondition; retrying', {
1962
+ app,
1963
+ machineId,
1964
+ attempt,
1965
+ attempts: EXEC_PRECONDITION_ATTEMPTS,
1966
+ state: state ?? 'unreadable',
1967
+ });
1968
+ // The Machine is up (that is what makes this 412 the transient one), but
1969
+ // re-read it anyway: `stop`/`suspend` can land between attempts, and
1970
+ // retrying an exec against a Machine that is not running would repeat a
1971
+ // 412 that waiting — not repeating — is what answers.
1972
+ await this.waitForExecReady(app, machineId, state);
1973
+ await this.client.sleep(execPreconditionBackoffMs(attempt));
1974
+ }
1975
+ }
1795
1976
  };
1796
1977
  }
1978
+ /**
1979
+ * Confirms the Machine is in a state an exec can run in, waiting for `started`
1980
+ * when it is still on its way up.
1981
+ *
1982
+ * A wait that does not reach `started` is logged and the exec is retried
1983
+ * anyway: the retry is the recovery this path exists for, and turning a
1984
+ * recoverable refusal into a start-timeout error would hide the condition the
1985
+ * caller's error message is supposed to name.
1986
+ *
1987
+ * @param app - The Fly app name.
1988
+ * @param machineId - The Fly Machine id.
1989
+ * @param state - The state already read for this Machine, when the caller read
1990
+ * one (the exec retry does); read again when omitted.
1991
+ */
1992
+ async waitForExecReady(app, machineId, state) {
1993
+ const machineState = state === undefined ? await this.readMachineState(app, machineId) : state;
1994
+ if (machineState === 'started')
1995
+ return;
1996
+ try {
1997
+ await this.waitForStarted(app, machineId);
1998
+ }
1999
+ catch (error) {
2000
+ logger.warn('The Machine did not report started after a refused exec; retrying the exec', {
2001
+ app,
2002
+ machineId,
2003
+ state: machineState ?? 'unknown',
2004
+ error,
2005
+ });
2006
+ }
2007
+ }
2008
+ /**
2009
+ * Names the Machine's state on an exec 412, so the caller is told WHICH state
2010
+ * the exec was refused in. Fly's own body says only `failed_precondition`.
2011
+ *
2012
+ * A state that cannot be read is reported as such rather than guessed, and any
2013
+ * non-412 error (or an error that already carries the state) is returned
2014
+ * untouched — this runs on the failure path, where a diagnostic must never
2015
+ * replace the failure it is describing.
2016
+ *
2017
+ * @param error - The failure to annotate.
2018
+ * @param app - The Fly app name.
2019
+ * @param machineId - The Fly Machine id.
2020
+ * @param knownState - The state already read for this Machine, when the caller
2021
+ * read one (the exec retry does); read again when omitted.
2022
+ * @returns The original error, or a 412 carrying the Machine's state.
2023
+ */
2024
+ async withMachineState(error, app, machineId, knownState) {
2025
+ if (!(error instanceof Error))
2026
+ return new Error(String(error));
2027
+ if (!(error instanceof FlyApiError) || error.status !== 412)
2028
+ return error;
2029
+ if (/machine state:/i.test(error.message))
2030
+ return error;
2031
+ const read = knownState === undefined ? await this.readMachineState(app, machineId) : knownState;
2032
+ const state = read ?? 'unreadable';
2033
+ return new FlyApiError(error.method, error.path, error.status, `${error.body} (machine ${machineId} state: ${state})`, { cause: error });
2034
+ }
1797
2035
  /**
1798
2036
  * Starts (or resumes) a Machine, tolerating the case where it is already up.
1799
2037
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@molecule/api-code-sandbox-flyio",
3
- "version": "1.2.8",
3
+ "version": "1.2.10",
4
4
  "description": "Fly.io Machines (Firecracker microVM) code sandbox provider",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",