Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-transport-retry-budget.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@cloudflare/sandbox': patch
---

Fix sandbox creation timing out for large container images even when startup timeouts are configured to allow enough time. The transport retry budget now automatically scales to match configured startup timeouts instead of being hard-coded at 120 seconds.
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 9 additions & 1 deletion packages/sandbox/src/clients/base-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,19 @@ export abstract class BaseHttpClient {
wsUrl: options.wsUrl,
logger: this.logger,
stub: options.stub,
port: options.port
port: options.port,
retryTimeoutMs: options.retryTimeoutMs
});
}
}

/**
* Update the transport's 503 retry budget
*/
setRetryTimeoutMs(ms: number): void {
this.transport.setRetryTimeoutMs(ms);
}

/**
* Check if using WebSocket transport
*/
Expand Down
29 changes: 28 additions & 1 deletion packages/sandbox/src/clients/sandbox-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ export class SandboxClient {
baseUrl: options.baseUrl,
logger: options.logger,
stub: options.stub,
port: options.port
port: options.port,
retryTimeoutMs: options.retryTimeoutMs
});
}

Expand All @@ -73,6 +74,32 @@ export class SandboxClient {
this.watch = new WatchClient(clientOptions);
}

/**
* Update the 503 retry budget on all transports without recreating the client.
*
* In WebSocket mode a single shared transport is used, so one update covers
* every sub-client. In HTTP mode each sub-client owns its own transport, so
* all of them are updated individually.
*/
setRetryTimeoutMs(ms: number): void {
if (this.transport) {
// WebSocket mode — single shared transport
this.transport.setRetryTimeoutMs(ms);
} else {
// HTTP mode — each sub-client has its own transport
this.backup.setRetryTimeoutMs(ms);
this.commands.setRetryTimeoutMs(ms);
this.files.setRetryTimeoutMs(ms);
this.processes.setRetryTimeoutMs(ms);
this.ports.setRetryTimeoutMs(ms);
this.git.setRetryTimeoutMs(ms);
this.interpreter.setRetryTimeoutMs(ms);
this.utils.setRetryTimeoutMs(ms);
this.desktop.setRetryTimeoutMs(ms);
this.watch.setRetryTimeoutMs(ms);
}
}

/**
* Get the current transport mode
*/
Expand Down
10 changes: 8 additions & 2 deletions packages/sandbox/src/clients/transport/base-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { ITransport, TransportConfig, TransportMode } from './types';
/**
* Container startup retry configuration
*/
const TIMEOUT_MS = 120_000; // 2 minutes total retry budget
const DEFAULT_RETRY_TIMEOUT_MS = 120_000; // 2 minutes total retry budget
const MIN_TIME_FOR_RETRY_MS = 15_000; // Need at least 15s remaining to retry

/**
Expand All @@ -17,17 +17,23 @@ const MIN_TIME_FOR_RETRY_MS = 15_000; // Need at least 15s remaining to retry
export abstract class BaseTransport implements ITransport {
protected config: TransportConfig;
protected logger: Logger;
private retryTimeoutMs: number;

constructor(config: TransportConfig) {
this.config = config;
this.logger = config.logger ?? createNoOpLogger();
this.retryTimeoutMs = config.retryTimeoutMs ?? DEFAULT_RETRY_TIMEOUT_MS;
}

abstract getMode(): TransportMode;
abstract connect(): Promise<void>;
abstract disconnect(): void;
abstract isConnected(): boolean;

setRetryTimeoutMs(ms: number): void {
this.retryTimeoutMs = ms;
}

/**
* Fetch with automatic retry for 503 (container starting)
*
Expand All @@ -44,7 +50,7 @@ export abstract class BaseTransport implements ITransport {
// Check for retryable 503 (container starting)
if (response.status === 503) {
const elapsed = Date.now() - startTime;
const remaining = TIMEOUT_MS - elapsed;
const remaining = this.retryTimeoutMs - elapsed;

if (remaining > MIN_TIME_FOR_RETRY_MS) {
const delay = Math.min(3000 * 2 ** attempt, 30000);
Expand Down
11 changes: 11 additions & 0 deletions packages/sandbox/src/clients/transport/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ export interface TransportConfig {

/** Connection timeout in milliseconds (WebSocket only) */
connectTimeoutMs?: number;

/** Total retry budget in milliseconds for 503 retries during container startup.
* Defaults to 120_000 (2 minutes). Should be at least as large as the sum of
* instanceGetTimeoutMS + portReadyTimeoutMS to avoid the client giving up
* before the container has finished starting. */
retryTimeoutMs?: number;
}

/**
Expand Down Expand Up @@ -74,4 +80,9 @@ export interface ITransport {
* Check if connected (always true for HTTP)
*/
isConnected(): boolean;

/**
* Update the 503 retry budget without recreating the transport
*/
setRetryTimeoutMs(ms: number): void;
}
6 changes: 6 additions & 0 deletions packages/sandbox/src/clients/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ export interface HttpClientOptions {
* When provided, clients will use this transport instead of creating their own.
*/
transport?: ITransport;

/**
* Total retry budget in milliseconds for 503 retries during container startup.
* Passed through to the transport layer. Defaults to 120_000 (2 minutes).
*/
retryTimeoutMs?: number;
}

/**
Expand Down
21 changes: 21 additions & 0 deletions packages/sandbox/src/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,21 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
return (fn as (...a: unknown[]) => unknown).apply(client, args);
}

/**
* Compute the transport retry budget from current container timeouts.
*
* The budget covers the full container startup window (instance provisioning
* + port readiness) plus a 30s margin for the maximum single backoff delay
* (capped at 30s in BaseTransport). The 120s floor preserves the previous
* default for short timeout configurations.
*/
private computeRetryTimeoutMs(): number {
const startupBudgetMs =
this.containerTimeouts.instanceGetTimeoutMS +
this.containerTimeouts.portReadyTimeoutMS;
return Math.max(120_000, startupBudgetMs + 30_000);
}

/**
* Create a SandboxClient with current transport settings
*/
Expand All @@ -324,6 +339,7 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
logger: this.logger,
port: 3000,
stub: this,
retryTimeoutMs: this.computeRetryTimeoutMs(),
...(this.transport === 'websocket' && {
transportMode: 'websocket' as const,
wsUrl: 'ws://localhost:3000/ws'
Expand Down Expand Up @@ -408,6 +424,8 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
...this.containerTimeouts,
...storedTimeouts
};
// Update the transport retry budget to reflect stored timeouts
this.client.setRetryTimeoutMs(this.computeRetryTimeoutMs());
}
});
}
Expand Down Expand Up @@ -530,6 +548,9 @@ export class Sandbox<Env = unknown> extends Container<Env> implements ISandbox {
// Persist to storage
await this.ctx.storage.put('containerTimeouts', this.containerTimeouts);

// Update the transport retry budget to reflect new timeouts
this.client.setRetryTimeoutMs(this.computeRetryTimeoutMs());

this.logger.debug('Container timeouts updated', this.containerTimeouts);
}

Expand Down
Loading