@blaxel/core 0.3.1-preview.210 → 0.3.1-preview.212

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.
@@ -22,8 +22,8 @@ function missingCredentialsMessage() {
22
22
  return "No Blaxel credentials found. Set the BL_API_KEY and BL_WORKSPACE environment variables, or run `bl login`.";
23
23
  }
24
24
  // Build info - these placeholders are replaced at build time by build:replace-imports
25
- const BUILD_VERSION = "0.3.1-preview.210";
26
- const BUILD_COMMIT = "1cf2f890404700f7be699c0a1ef0cf137db7efcf";
25
+ const BUILD_VERSION = "0.3.1-preview.212";
26
+ const BUILD_COMMIT = "06b4bb451ef8797cbc2cd3e3a957d62c45f2cc12";
27
27
  const BUILD_SENTRY_DSN = "https://fd5e60e1c9820e1eef5ccebb84a07127@o4508714045276160.ingest.us.sentry.io/4510465864564736";
28
28
  const BLAXEL_API_VERSION = "2026-04-28";
29
29
  // Cache for config.yaml tracking value
@@ -103,8 +103,9 @@ const DEFAULT_MAX_DELAY_MS = 2000;
103
103
  // single wait never blocks unreasonably long. Exponential (rather than linear)
104
104
  // gives a later attempt room to span a multi-second sandbox cold-start/standby
105
105
  // wake, which is the window a first-call reset falls into, while early attempts
106
- // stay fast for the common quick-reset case.
107
- function backoffDelayMs(attempt, baseDelayMs, maxDelayMs) {
106
+ // stay fast for the common quick-reset case. Exported so other polling paths
107
+ // (e.g. the create-504 wait in sandbox.ts) share the same delay curve.
108
+ export function backoffDelayMs(attempt, baseDelayMs, maxDelayMs) {
108
109
  const exponential = baseDelayMs * Math.pow(2, attempt - 1);
109
110
  const capped = Math.min(exponential, maxDelayMs);
110
111
  const jitter = Math.floor(Math.random() * baseDelayMs);
@@ -1,6 +1,7 @@
1
1
  import { v4 as uuidv4 } from "uuid";
2
2
  import { createSandbox, deleteSandbox, getSandbox, getSandboxByExternalId, listSandboxes, updateSandbox } from "../client/index.js";
3
3
  import { logger } from "../common/logger.js";
4
+ import { backoffDelayMs } from "../common/transient-retry.js";
4
5
  import { createPaginatedList } from "../common/pagination.js";
5
6
  import { settings } from "../common/settings.js";
6
7
  import { SandboxCodegen } from "./codegen/index.js";
@@ -31,6 +32,15 @@ const TRANSIENT_SANDBOX_STATUSES = new Set([
31
32
  ]);
32
33
  const TRANSIENT_STATUS_MAX_WAIT_MS = 30_000;
33
34
  const TRANSIENT_STATUS_POLL_MS = 500;
35
+ // A create that outlives the edge's 60s origin-read timeout gets a 504 from
36
+ // CloudFront while the control plane keeps deploying the sandbox for up to
37
+ // 300s (ENG-3662 timeout ladder inversion). The 504 body is edge HTML with no
38
+ // usable payload, so the record is polled instead until it settles.
39
+ // Polling backs off exponentially (1s, 2s, 4s, then 5s + jitter) so a fleet of
40
+ // clients stuck in this window does not hammer the control plane every second.
41
+ const CREATE_GATEWAY_TIMEOUT_MAX_WAIT_MS = 120_000;
42
+ const CREATE_GATEWAY_TIMEOUT_BASE_POLL_MS = 1_000;
43
+ const CREATE_GATEWAY_TIMEOUT_MAX_POLL_MS = 5_000;
34
44
  const isSandboxNotFound = (e) => {
35
45
  if (typeof e !== "object" || e === null)
36
46
  return false;
@@ -205,14 +215,25 @@ export class SandboxInstance {
205
215
  if (edgeDomain && !settings.disableH2) {
206
216
  import("../common/h2pool.js").then(({ h2Pool }) => h2Pool.warm(edgeDomain)).catch(() => { });
207
217
  }
208
- const [{ data }, h2Session] = await Promise.all([
218
+ const [createResult, h2Session] = await Promise.all([
209
219
  createSandbox({
210
220
  body: sandbox,
211
221
  query: createIfNotExist ? { createIfNotExist } : undefined,
212
- throwOnError: true,
213
222
  }),
214
223
  edgeDomain && !settings.disableH2 ? import("../common/h2pool.js").then(({ h2Pool }) => h2Pool.get(edgeDomain)).catch(() => null) : Promise.resolve(null),
215
224
  ]);
225
+ let data = createResult.data;
226
+ if (createResult.error !== undefined) {
227
+ const name = sandbox.metadata.name;
228
+ if (createResult.response.status === 504 && name) {
229
+ // The edge gave up on the connection but the creation is still running
230
+ // server-side; wait for the record instead of failing (ENG-3662).
231
+ data = await SandboxInstance.waitAfterCreateGatewayTimeout(name, createResult.error);
232
+ }
233
+ else {
234
+ throw createResult.error;
235
+ }
236
+ }
216
237
  // Inject the H2 session into the config so subsystems can use it
217
238
  const config = { ...data, h2Session, h2Domain: settings.disableH2 ? null : edgeDomain };
218
239
  const instance = new SandboxInstance(config);
@@ -352,6 +373,12 @@ export class SandboxInstance {
352
373
  static async createIfNotExists(sandbox) {
353
374
  const ATTEMPTS = 3;
354
375
  let lastStatus = "unknown";
376
+ // The 'vanished' window (create 409s while get 404s) is driven by the same
377
+ // transient causes as 'dying': an in-flight delete finishing, or a
378
+ // concurrent create whose row is not readable yet. Give it the same time
379
+ // budget as waitWhileSandboxDying instead of burning the attempt budget
380
+ // 500ms apart (~1s total), which threw on deletes taking >1s (ENG-3667).
381
+ const vanishedDeadline = Date.now() + TRANSIENT_STATUS_MAX_WAIT_MS;
355
382
  for (let i = 0; i < ATTEMPTS; ++i) {
356
383
  const finalAttempt = i === ATTEMPTS - 1;
357
384
  try {
@@ -363,6 +390,12 @@ export class SandboxInstance {
363
390
  if (!name) {
364
391
  throw new Error("Sandbox name is required");
365
392
  }
393
+ // The controlplane tags the creation-lock 409 with
394
+ // reason=CREATION_IN_PROGRESS when the conflict comes from a
395
+ // concurrent create still in flight (ENG-3776). The field is absent
396
+ // on older controlplanes and on real row conflicts, so it only
397
+ // sharpens the give-up error; the polling behavior is the same.
398
+ const creationInProgress = e.reason === "CREATION_IN_PROGRESS";
366
399
  // Get the existing sandbox to check its status
367
400
  let sandboxInstance;
368
401
  try {
@@ -370,10 +403,14 @@ export class SandboxInstance {
370
403
  }
371
404
  catch (getError) {
372
405
  if (isSandboxNotFound(getError)) {
373
- // The record vanished between the create conflict and this status check
374
- // (its deletion just finished); give the control plane a beat and retry.
375
- lastStatus = "vanished";
376
- if (!finalAttempt) {
406
+ // The record vanished between the create conflict and this status check.
407
+ lastStatus = creationInProgress ? "creation in progress" : "vanished";
408
+ if (Date.now() < vanishedDeadline) {
409
+ // Inside the transient window: poll without consuming attempts.
410
+ await new Promise((resolve) => setTimeout(resolve, TRANSIENT_STATUS_POLL_MS));
411
+ --i;
412
+ }
413
+ else if (!finalAttempt) {
377
414
  await new Promise((resolve) => setTimeout(resolve, TRANSIENT_STATUS_POLL_MS));
378
415
  }
379
416
  continue;
@@ -399,6 +436,35 @@ export class SandboxInstance {
399
436
  }
400
437
  throw new Error(`Unable to create sandbox after ${ATTEMPTS} attempts. Last conflicting status: ${lastStatus}.`);
401
438
  }
439
+ // Poll the record after a create was cut by the edge with a 504 while the
440
+ // control plane is still deploying it. Resolves with the sandbox once it
441
+ // reaches DEPLOYED; throws on FAILED or once the wait budget is spent.
442
+ static async waitAfterCreateGatewayTimeout(name, createError) {
443
+ logger.debug(`Sandbox ${name} creation timed out at the edge (504); polling the record while it finishes deploying`);
444
+ const deadline = Date.now() + CREATE_GATEWAY_TIMEOUT_MAX_WAIT_MS;
445
+ let attempt = 0;
446
+ while (Date.now() < deadline) {
447
+ attempt++;
448
+ await new Promise((resolve) => setTimeout(resolve, backoffDelayMs(attempt, CREATE_GATEWAY_TIMEOUT_BASE_POLL_MS, CREATE_GATEWAY_TIMEOUT_MAX_POLL_MS)));
449
+ let current;
450
+ try {
451
+ const { data } = await getSandbox({ path: { sandboxName: name }, throwOnError: true });
452
+ current = data;
453
+ }
454
+ catch (e) {
455
+ // The record can lag behind the accepted create; keep waiting on 404.
456
+ if (isSandboxNotFound(e))
457
+ continue;
458
+ throw e;
459
+ }
460
+ if (current.status === "DEPLOYED")
461
+ return current;
462
+ if (current.status === "FAILED") {
463
+ throw new Error(`Sandbox ${name} failed to deploy after the create timed out at the edge (504).`);
464
+ }
465
+ }
466
+ throw createError;
467
+ }
402
468
  // Poll the record until an in-flight delete/deactivation settles (or the record
403
469
  // disappears), bounded by TRANSIENT_STATUS_MAX_WAIT_MS. Errors from get (e.g. 404
404
470
  // once the record is gone) end the wait: the caller's create retry decides next.