@cloudflare/sandbox 0.4.17 → 0.5.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudflare/sandbox",
3
- "version": "0.4.17",
3
+ "version": "0.5.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/cloudflare/sandbox-sdk"
@@ -40,5 +40,5 @@
40
40
  },
41
41
  "keywords": [],
42
42
  "author": "",
43
- "license": "ISC"
43
+ "license": "Apache-2.0"
44
44
  }
@@ -6,9 +6,9 @@ import { createErrorFromResponse, ErrorCode } from '../errors';
6
6
  import type { SandboxError } from '../errors/classes';
7
7
  import type { HttpClientOptions, ResponseHandler } from './types';
8
8
 
9
- // Container provisioning retry configuration
10
- const TIMEOUT_MS = 60_000; // 60 seconds total timeout budget
11
- const MIN_TIME_FOR_RETRY_MS = 10_000; // Need at least 10s remaining to retry (8s Container + 2s delay)
9
+ // Container startup retry configuration
10
+ const TIMEOUT_MS = 120_000; // 2 minutes total retry budget
11
+ const MIN_TIME_FOR_RETRY_MS = 15_000; // Need at least 15s remaining to retry (allows for longer container startups)
12
12
 
13
13
  /**
14
14
  * Abstract base class providing common HTTP functionality for all domain clients
@@ -25,7 +25,8 @@ export abstract class BaseHttpClient {
25
25
  }
26
26
 
27
27
  /**
28
- * Core HTTP request method with automatic retry for container provisioning delays
28
+ * Core HTTP request method with automatic retry for container startup delays
29
+ * Retries both 503 (provisioning) and 500 (startup failure) errors when they're container-related
29
30
  */
30
31
  protected async doFetch(
31
32
  path: string,
@@ -37,42 +38,41 @@ export abstract class BaseHttpClient {
37
38
  while (true) {
38
39
  const response = await this.executeFetch(path, options);
39
40
 
40
- // Only retry container provisioning 503s, not user app 503s
41
- if (response.status === 503) {
42
- const isContainerProvisioning =
43
- await this.isContainerProvisioningError(response);
44
-
45
- if (isContainerProvisioning) {
46
- const elapsed = Date.now() - startTime;
47
- const remaining = TIMEOUT_MS - elapsed;
48
-
49
- // Check if we have enough time for another attempt
50
- // (Need at least 10s: 8s for Container timeout + 2s delay)
51
- if (remaining > MIN_TIME_FOR_RETRY_MS) {
52
- // Exponential backoff: 2s, 4s, 8s, 16s (capped at 16s)
53
- const delay = Math.min(2000 * 2 ** attempt, 16000);
54
-
55
- this.logger.info('Container provisioning in progress, retrying', {
56
- attempt: attempt + 1,
57
- delayMs: delay,
58
- remainingSec: Math.floor(remaining / 1000)
59
- });
60
-
61
- await new Promise((resolve) => setTimeout(resolve, delay));
62
- attempt++;
63
- continue;
64
- } else {
65
- // Exhausted retries - log error and return response
66
- // Let existing error handling convert to proper error
67
- this.logger.error(
68
- 'Container failed to provision after multiple attempts',
69
- new Error(`Failed after ${attempt + 1} attempts over 60s`)
70
- );
71
- return response;
72
- }
41
+ // Check if this is a retryable container error (both 500 and 503)
42
+ const shouldRetry = await this.isRetryableContainerError(response);
43
+
44
+ if (shouldRetry) {
45
+ const elapsed = Date.now() - startTime;
46
+ const remaining = TIMEOUT_MS - elapsed;
47
+
48
+ // Check if we have enough time for another attempt
49
+ if (remaining > MIN_TIME_FOR_RETRY_MS) {
50
+ // Exponential backoff with longer delays for container ops: 3s, 6s, 12s, 24s, 30s
51
+ const delay = Math.min(3000 * 2 ** attempt, 30000);
52
+
53
+ this.logger.info('Container not ready, retrying', {
54
+ status: response.status,
55
+ attempt: attempt + 1,
56
+ delayMs: delay,
57
+ remainingSec: Math.floor(remaining / 1000)
58
+ });
59
+
60
+ await new Promise((resolve) => setTimeout(resolve, delay));
61
+ attempt++;
62
+ continue;
73
63
  }
64
+
65
+ // Timeout exhausted
66
+ this.logger.error(
67
+ 'Container failed to become ready',
68
+ new Error(
69
+ `Failed after ${attempt + 1} attempts over ${Math.floor(elapsed / 1000)}s`
70
+ )
71
+ );
72
+ return response;
74
73
  }
75
74
 
75
+ // Not a retryable error or request succeeded
76
76
  return response;
77
77
  }
78
78
  }
@@ -82,7 +82,7 @@ export abstract class BaseHttpClient {
82
82
  */
83
83
  protected async post<T>(
84
84
  endpoint: string,
85
- data: Record<string, any>,
85
+ data: unknown,
86
86
  responseHandler?: ResponseHandler<T>
87
87
  ): Promise<T> {
88
88
  const response = await this.doFetch(endpoint, {
@@ -242,25 +242,86 @@ export abstract class BaseHttpClient {
242
242
  }
243
243
 
244
244
  /**
245
- * Check if 503 response is from container provisioning (retryable)
246
- * vs user application (not retryable)
245
+ * Check if response indicates a retryable container error
246
+ * Uses fail-safe strategy: only retry known transient errors
247
+ *
248
+ * TODO: This relies on string matching error messages, which is brittle.
249
+ * Ideally, the container API should return structured errors with a
250
+ * `retryable: boolean` field to avoid coupling to error message format.
251
+ *
252
+ * @param response - HTTP response to check
253
+ * @returns true if error is retryable container error, false otherwise
247
254
  */
248
- private async isContainerProvisioningError(
255
+ private async isRetryableContainerError(
249
256
  response: Response
250
257
  ): Promise<boolean> {
258
+ // Only consider 500 and 503 status codes
259
+ if (response.status !== 500 && response.status !== 503) {
260
+ return false;
261
+ }
262
+
251
263
  try {
252
- // Clone response so we don't consume the original body
253
264
  const cloned = response.clone();
254
265
  const text = await cloned.text();
266
+ const textLower = text.toLowerCase();
267
+
268
+ // Step 1: Check for permanent errors (fail fast)
269
+ const permanentErrors = [
270
+ 'no such image', // Missing Docker image
271
+ 'container already exists', // Name collision
272
+ 'malformed containerinspect' // Docker API issue
273
+ ];
274
+
275
+ if (permanentErrors.some((err) => textLower.includes(err))) {
276
+ this.logger.debug('Detected permanent error, not retrying', { text });
277
+ return false; // Don't retry
278
+ }
279
+
280
+ // Step 2: Check for known transient errors (do retry)
281
+ const transientErrors = [
282
+ // Platform provisioning (503)
283
+ 'no container instance available',
284
+ 'currently provisioning',
285
+
286
+ // Port mapping race conditions (500)
287
+ 'container port not found',
288
+ 'connection refused: container port',
289
+
290
+ // Application startup delays (500)
291
+ 'the container is not listening',
292
+ 'failed to verify port',
293
+ 'container did not start',
294
+
295
+ // Network transients (500)
296
+ 'network connection lost',
297
+ 'container suddenly disconnected',
298
+
299
+ // Monitor race conditions (500)
300
+ 'monitor failed to find container',
301
+
302
+ // General timeouts (500)
303
+ 'timed out',
304
+ 'timeout'
305
+ ];
306
+
307
+ const shouldRetry = transientErrors.some((err) =>
308
+ textLower.includes(err)
309
+ );
310
+
311
+ if (!shouldRetry) {
312
+ this.logger.debug('Unknown error pattern, not retrying', {
313
+ status: response.status,
314
+ text: text.substring(0, 200) // Log first 200 chars
315
+ });
316
+ }
255
317
 
256
- // Container package returns specific message for provisioning errors
257
- return text.includes('There is no Container instance available');
318
+ return shouldRetry;
258
319
  } catch (error) {
259
320
  this.logger.error(
260
- 'Error checking response body',
321
+ 'Error checking if response is retryable',
261
322
  error instanceof Error ? error : new Error(String(error))
262
323
  );
263
- // If we can't read the body, don't retry to be safe
324
+ // If we can't read response, don't retry (fail fast)
264
325
  return false;
265
326
  }
266
327
  }
@@ -98,7 +98,7 @@ export class FileClient extends BaseHttpClient {
98
98
  path,
99
99
  content,
100
100
  sessionId,
101
- encoding: options?.encoding ?? 'utf8'
101
+ encoding: options?.encoding
102
102
  };
103
103
 
104
104
  const response = await this.post<WriteFileResult>('/api/write', data);
@@ -126,7 +126,7 @@ export class FileClient extends BaseHttpClient {
126
126
  const data = {
127
127
  path,
128
128
  sessionId,
129
- encoding: options?.encoding ?? 'utf8'
129
+ encoding: options?.encoding
130
130
  };
131
131
 
132
132
  const response = await this.post<ReadFileResult>('/api/read', data);
package/src/index.ts CHANGED
@@ -17,20 +17,31 @@ export { getSandbox, Sandbox } from './sandbox';
17
17
  // Export core SDK types for consumers
18
18
  export type {
19
19
  BaseExecOptions,
20
+ BucketCredentials,
21
+ BucketProvider,
22
+ CodeContext,
23
+ CreateContextOptions,
20
24
  ExecEvent,
21
25
  ExecOptions,
22
26
  ExecResult,
27
+ ExecutionResult,
28
+ ExecutionSession,
23
29
  FileChunk,
24
30
  FileMetadata,
25
31
  FileStreamEvent,
32
+ GitCheckoutResult,
26
33
  ISandbox,
34
+ ListFilesOptions,
27
35
  LogEvent,
36
+ MountBucketOptions,
28
37
  Process,
29
38
  ProcessOptions,
30
39
  ProcessStatus,
40
+ RunCodeOptions,
41
+ SandboxOptions,
42
+ SessionOptions,
31
43
  StreamOptions
32
44
  } from '@repo/shared';
33
- export * from '@repo/shared';
34
45
  // Export type guards for runtime validation
35
46
  export { isExecResult, isProcess, isProcessStatus } from '@repo/shared';
36
47
  // Export all client types from new architecture
@@ -56,7 +67,6 @@ export type {
56
67
 
57
68
  // Git client types
58
69
  GitCheckoutRequest,
59
- GitCheckoutResult,
60
70
  // Base client types
61
71
  HttpClientOptions as SandboxClientOptions,
62
72
 
@@ -102,3 +112,10 @@ export {
102
112
  parseSSEStream,
103
113
  responseToAsyncIterable
104
114
  } from './sse-parser';
115
+ // Export bucket mounting errors
116
+ export {
117
+ BucketMountError,
118
+ InvalidMountConfigError,
119
+ MissingCredentialsError,
120
+ S3FSMountError
121
+ } from './storage-mount/errors';
@@ -36,7 +36,8 @@ export async function proxyToSandbox<E extends SandboxEnv>(
36
36
  }
37
37
 
38
38
  const { sandboxId, port, path, token } = routeInfo;
39
- const sandbox = getSandbox(env.Sandbox, sandboxId);
39
+ // Preview URLs always use normalized (lowercase) IDs
40
+ const sandbox = getSandbox(env.Sandbox, sandboxId, { normalizeId: true });
40
41
 
41
42
  // Critical security check: Validate token (mandatory for all user ports)
42
43
  // Skip check for control plane port 3000