@molecule/api-code-sandbox-flyio 1.2.7 → 1.2.9

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,23 @@ 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 — the request reached
15
+ * the Machine's exec agent and the connection was dropped before the command
16
+ * ran (`412 failed_precondition: exec request failed: EOF`) — rather than the
17
+ * Machine being in a state an exec cannot run in.
18
+ *
19
+ * The distinction decides what retrying can achieve. A dropped connection is
20
+ * worth repeating: the Machine is `started` and its agent recovers in seconds.
21
+ * `failed_precondition: machine not running` is NOT — the Machine really is not
22
+ * running, and that is what {@link FlyioSandboxProvider.startMachine} waits out
23
+ * before the first exec. Repeating the exec there would only burn the caller's
24
+ * budget against a condition the wait endpoint already answers.
25
+ *
26
+ * @param error - The failure an exec attempt raised.
27
+ * @returns `true` when repeating the exec is the right recovery.
28
+ */
29
+ export declare function isTransientExecPrecondition(error: unknown): boolean;
13
30
  /**
14
31
  * Creates a Fly.io Machines sandbox provider.
15
32
  * @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;AAigFnB;;;;;;;;;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;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,2BAA2B,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAGnE;AA8oFD;;;;;;;;;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,27 @@ 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 — the request reached
36
+ * the Machine's exec agent and the connection was dropped before the command
37
+ * ran (`412 failed_precondition: exec request failed: EOF`) — rather than the
38
+ * Machine being in a state an exec cannot run in.
39
+ *
40
+ * The distinction decides what retrying can achieve. A dropped connection is
41
+ * worth repeating: the Machine is `started` and its agent recovers in seconds.
42
+ * `failed_precondition: machine not running` is NOT — the Machine really is not
43
+ * running, and that is what {@link FlyioSandboxProvider.startMachine} waits out
44
+ * before the first exec. Repeating the exec there would only burn the caller's
45
+ * budget against a condition the wait endpoint already answers.
46
+ *
47
+ * @param error - The failure an exec attempt raised.
48
+ * @returns `true` when repeating the exec is the right recovery.
49
+ */
50
+ export function isTransientExecPrecondition(error) {
51
+ if (!(error instanceof FlyApiError) || error.status !== 412)
52
+ return false;
53
+ return EXEC_BODY_TRANSIENT.test(error.body) && !EXEC_BODY_MACHINE_NOT_RUNNING.test(error.body);
54
+ }
34
55
  /**
35
56
  * Default sandbox image. Fly pulls images itself, so this MUST be a reference
36
57
  * Fly can resolve — a tag in the org's `registry.fly.io` repository or a public
@@ -149,10 +170,59 @@ const MACHINE_CREATE_ATTEMPTS = 6;
149
170
  /**
150
171
  * Retrying a `POST /machines` on 404 is SAFE precisely because "app not found"
151
172
  * means no Machine was created — unlike a 5xx/timeout, where one might have
152
- * been. So only 404 is added here, on top of the client's default retryable
153
- * set; the retry cannot double-create.
173
+ * been. 412 is safe for the same reason: "volume already claimed by machine
174
+ * <id>" refuses the create. It is what the create answers in the seconds after
175
+ * {@link FlyioSandboxProvider.destroyStaleMachines} removed the Machine that
176
+ * held the volume — Fly releases the claim asynchronously, and an immediate
177
+ * create lost a production deploy to it (2026-10-14; the identical retry a
178
+ * minute later succeeded). Only these two are added on top of the client's
179
+ * default retryable set; neither retry can double-create.
180
+ */
181
+ const MACHINE_CREATE_RETRY_STATUSES = [404, 412];
182
+ /**
183
+ * What Fly answers when it reached the exec endpoint but the connection to the
184
+ * Machine's exec agent died before the command ran: `412 {"error":
185
+ * "failed_precondition: exec request failed: EOF"}`. The Machine is `started`;
186
+ * the agent inside it dropped the request (a Machine seconds past boot, or
187
+ * Fly's own exec burst). The SAME status carries the precondition this provider
188
+ * already handles — `failed_precondition: machine not running` — which is a
189
+ * real state, not a dropped connection, and is reconciled by `startMachine`
190
+ * BEFORE the first exec instead of by repeating it (see {@link
191
+ * isTransientExecPrecondition}).
192
+ */
193
+ const EXEC_BODY_TRANSIENT = /exec request failed/i;
194
+ /** The real-precondition 412, which retrying an exec cannot fix. */
195
+ const EXEC_BODY_MACHINE_NOT_RUNNING = /machine not running/i;
196
+ /**
197
+ * Attempts for one exec when Fly answers the transient 412 of
198
+ * {@link EXEC_BODY_TRANSIENT}. Four attempts over the three backoffs below is
199
+ * under 12 s of waiting, against the ~75 s each fresh production deploy costs
200
+ * when this is left to a caller: X1 run flash-m85 (2026-10-16) lost three
201
+ * consecutive deploys, and the run, to this one condition.
202
+ */
203
+ const EXEC_PRECONDITION_ATTEMPTS = 4;
204
+ /**
205
+ * Backoff before each exec retry (ms), indexed by the failed attempt number.
206
+ * Short on purpose: the Machine is up, and the agent recovers in seconds —
207
+ * nothing here is waiting on a boot.
208
+ */
209
+ const EXEC_PRECONDITION_BACKOFF_MS = [1_000, 3_000, 7_000];
210
+ /**
211
+ * Wall-clock ceiling on {@link EXEC_PRECONDITION_ATTEMPTS}, so a Machine whose
212
+ * exec agent keeps dropping requests cannot hold a caller for the whole budget
213
+ * while the rest of the backoff ladder runs. Measured from the first attempt.
154
214
  */
155
- const MACHINE_CREATE_RETRY_STATUSES = [404];
215
+ const EXEC_PRECONDITION_BUDGET_MS = 60_000;
216
+ /**
217
+ * The backoff before an exec retry, clamped to the last rung so an attempt
218
+ * count raised past the ladder's length cannot read off the end of it.
219
+ * @param attempt - The 1-based number of the attempt that just failed.
220
+ * @returns The delay in milliseconds.
221
+ */
222
+ function execPreconditionBackoffMs(attempt) {
223
+ const last = EXEC_PRECONDITION_BACKOFF_MS.length - 1;
224
+ return EXEC_PRECONDITION_BACKOFF_MS[Math.min(Math.max(attempt - 1, 0), last)];
225
+ }
156
226
  /**
157
227
  * Base64 chunk size for {@link Sandbox.importFiles}'s temp-file write. Bounded by
158
228
  * the same Fly exec payload limit as `WRITE_CHUNK_BASE64` (~15 KB, silently
@@ -1769,26 +1839,126 @@ class FlyioSandboxProvider {
1769
1839
  // ---------------------------------------------------------------------------
1770
1840
  /**
1771
1841
  * Issues one Fly exec call against a Machine.
1842
+ *
1843
+ * A transient 412 (see {@link isTransientExecPrecondition}) is retried here, at
1844
+ * the transport, because everywhere it surfaces — a boot, the production
1845
+ * artifact copy, the boot script, a health probe — the caller's alternative is
1846
+ * to throw the whole operation away and start over, which costs tens of seconds
1847
+ * or minutes. Before the retry the Machine's state is re-read (and waited on
1848
+ * when it is not yet `started`), so a Machine that really is still coming up is
1849
+ * not hammered.
1850
+ *
1851
+ * A 412 that is NOT that condition is never retried, and its message carries
1852
+ * the Machine's current state — the caller is told which state the exec was
1853
+ * refused in rather than a bare `failed_precondition`.
1854
+ *
1772
1855
  * @param app - The Fly app name.
1773
1856
  * @param machineId - The Fly Machine id.
1774
1857
  * @returns A transport callback for {@link execCommand}.
1775
1858
  */
1776
1859
  rawExec(app, machineId) {
1777
1860
  return async (command, timeoutSeconds) => {
1778
- const response = await this.client.request(`/apps/${app}/machines/${machineId}/exec`, {
1779
- method: 'POST',
1780
- body: { command, timeout: timeoutSeconds },
1781
- // The API's own timeout bounds the command; allow the HTTP request a
1782
- // little longer so the transport never fires first.
1783
- timeoutMs: (timeoutSeconds + 15) * 1000,
1784
- // A 404 on a Machine we created and are actively driving (e.g. the
1785
- // exec burst importFiles issues) is Fly API inconsistency under load,
1786
- // not a real absence — retry it alongside the default 429/5xx.
1787
- retryStatuses: [404],
1788
- });
1789
- return response ?? {};
1861
+ const deadline = Date.now() + EXEC_PRECONDITION_BUDGET_MS;
1862
+ for (let attempt = 1;; attempt++) {
1863
+ try {
1864
+ const response = await this.client.request(`/apps/${app}/machines/${machineId}/exec`, {
1865
+ method: 'POST',
1866
+ body: { command, timeout: timeoutSeconds },
1867
+ // The API's own timeout bounds the command; allow the HTTP request a
1868
+ // little longer so the transport never fires first.
1869
+ timeoutMs: (timeoutSeconds + 15) * 1000,
1870
+ // A 404 on a Machine we created and are actively driving (e.g. the
1871
+ // exec burst importFiles issues) is Fly API inconsistency under load,
1872
+ // not a real absence — retry it alongside the default 429/5xx.
1873
+ retryStatuses: [404],
1874
+ });
1875
+ return response ?? {};
1876
+ }
1877
+ catch (error) {
1878
+ if (!isTransientExecPrecondition(error)) {
1879
+ throw await this.withMachineState(error, app, machineId);
1880
+ }
1881
+ if (attempt >= EXEC_PRECONDITION_ATTEMPTS || Date.now() >= deadline) {
1882
+ logger.warn('Fly kept dropping the exec connection; giving up on this exec', {
1883
+ app,
1884
+ machineId,
1885
+ attempt,
1886
+ });
1887
+ throw await this.withMachineState(error, app, machineId);
1888
+ }
1889
+ logger.warn('Fly dropped the exec connection (412 exec request failed); retrying', {
1890
+ app,
1891
+ machineId,
1892
+ attempt,
1893
+ attempts: EXEC_PRECONDITION_ATTEMPTS,
1894
+ });
1895
+ // The Machine is up (that is what makes this 412 the transient one), but
1896
+ // re-read it anyway: `stop`/`suspend` can land between attempts, and
1897
+ // retrying an exec against a Machine that is not running would repeat a
1898
+ // 412 that waiting — not repeating — is what answers.
1899
+ await this.waitForExecReady(app, machineId);
1900
+ await this.client.sleep(execPreconditionBackoffMs(attempt));
1901
+ }
1902
+ }
1790
1903
  };
1791
1904
  }
1905
+ /**
1906
+ * Confirms the Machine is in a state an exec can run in, waiting for `started`
1907
+ * when it is still on its way up.
1908
+ *
1909
+ * A wait that does not reach `started` is logged and the exec is retried
1910
+ * anyway: the retry is the recovery this path exists for, and turning a
1911
+ * recoverable dropped connection into a start-timeout error would hide the
1912
+ * condition the caller's error message is supposed to name.
1913
+ *
1914
+ * @param app - The Fly app name.
1915
+ * @param machineId - The Fly Machine id.
1916
+ */
1917
+ async waitForExecReady(app, machineId) {
1918
+ const machine = await this.client
1919
+ .request(`/apps/${app}/machines/${machineId}`, { nullOn: [404] })
1920
+ .catch(() => null);
1921
+ if (machine?.state === 'started')
1922
+ return;
1923
+ try {
1924
+ await this.waitForStarted(app, machineId);
1925
+ }
1926
+ catch (error) {
1927
+ logger.warn('The Machine did not report started after a refused exec; retrying the exec', {
1928
+ app,
1929
+ machineId,
1930
+ state: machine?.state ?? 'unknown',
1931
+ error,
1932
+ });
1933
+ }
1934
+ }
1935
+ /**
1936
+ * Names the Machine's state on an exec 412, so the caller is told WHICH state
1937
+ * the exec was refused in. Fly's own body says only `failed_precondition`.
1938
+ *
1939
+ * A state that cannot be read is reported as such rather than guessed, and any
1940
+ * non-412 error (or an error that already carries the state) is returned
1941
+ * untouched — this runs on the failure path, where a diagnostic must never
1942
+ * replace the failure it is describing.
1943
+ *
1944
+ * @param error - The failure to annotate.
1945
+ * @param app - The Fly app name.
1946
+ * @param machineId - The Fly Machine id.
1947
+ * @returns The original error, or a 412 carrying the Machine's state.
1948
+ */
1949
+ async withMachineState(error, app, machineId) {
1950
+ if (!(error instanceof Error))
1951
+ return new Error(String(error));
1952
+ if (!(error instanceof FlyApiError) || error.status !== 412)
1953
+ return error;
1954
+ if (/machine state:/i.test(error.message))
1955
+ return error;
1956
+ const machine = await this.client
1957
+ .request(`/apps/${app}/machines/${machineId}`, { nullOn: [404] })
1958
+ .catch(() => null);
1959
+ const state = machine?.state ?? 'unreadable';
1960
+ return new FlyApiError(error.method, error.path, error.status, `${error.body} (machine ${machineId} state: ${state})`, { cause: error });
1961
+ }
1792
1962
  /**
1793
1963
  * Starts (or resumes) a Machine, tolerating the case where it is already up.
1794
1964
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@molecule/api-code-sandbox-flyio",
3
- "version": "1.2.7",
3
+ "version": "1.2.9",
4
4
  "description": "Fly.io Machines (Firecracker microVM) code sandbox provider",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",