@cloudflare/sandbox 0.4.18 → 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/.turbo/turbo-build.log +8 -8
- package/CHANGELOG.md +52 -0
- package/Dockerfile +5 -1
- package/LICENSE +176 -0
- package/README.md +1 -1
- package/dist/index.d.ts +296 -312
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +525 -55
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/clients/base-client.ts +107 -46
- package/src/index.ts +19 -2
- package/src/request-handler.ts +2 -1
- package/src/sandbox.ts +637 -24
- package/src/storage-mount/credential-detection.ts +41 -0
- package/src/storage-mount/errors.ts +51 -0
- package/src/storage-mount/index.ts +17 -0
- package/src/storage-mount/provider-detection.ts +93 -0
- package/src/storage-mount/types.ts +17 -0
- package/src/version.ts +1 -1
- package/tests/base-client.test.ts +218 -0
- package/tests/get-sandbox.test.ts +24 -1
- package/tests/sandbox.test.ts +121 -0
- package/tests/storage-mount/credential-detection.test.ts +119 -0
- package/tests/storage-mount/provider-detection.test.ts +77 -0
- package/tsdown.config.ts +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloudflare/sandbox",
|
|
3
|
-
"version": "0.
|
|
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": "
|
|
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
|
|
10
|
-
const TIMEOUT_MS =
|
|
11
|
-
const MIN_TIME_FOR_RETRY_MS =
|
|
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
|
|
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
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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:
|
|
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
|
|
246
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
324
|
+
// If we can't read response, don't retry (fail fast)
|
|
264
325
|
return false;
|
|
265
326
|
}
|
|
266
327
|
}
|
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';
|
package/src/request-handler.ts
CHANGED
|
@@ -36,7 +36,8 @@ export async function proxyToSandbox<E extends SandboxEnv>(
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
const { sandboxId, port, path, token } = routeInfo;
|
|
39
|
-
|
|
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
|