From c8fdaed6bfbcf2dda8380312bce6e1ca04f6f7f0 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:58:52 +0200 Subject: [PATCH 1/7] fix: set Content-Length on presigned PUT uploads for S3 compatibility S3 presigned PUT URLs do not support Transfer-Encoding: chunked. The JS SDK was streaming the gzipped tar body via fetch without Content-Length, causing 501 NotImplemented on S3-backed deployments. Buffer the compressed tar and set Content-Length explicitly in both JS and Python SDKs. Closes #1235 Closes #1243 Co-Authored-By: Claude Opus 4.6 --- packages/js-sdk/src/template/buildApi.ts | 17 +++++++++++++---- packages/js-sdk/src/template/utils.ts | 3 ++- .../python-sdk/e2b/template_async/build_api.py | 9 ++++++++- .../python-sdk/e2b/template_sync/build_api.py | 10 +++++++++- 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/packages/js-sdk/src/template/buildApi.ts b/packages/js-sdk/src/template/buildApi.ts index d699efe03c..34a44152d4 100644 --- a/packages/js-sdk/src/template/buildApi.ts +++ b/packages/js-sdk/src/template/buildApi.ts @@ -119,12 +119,21 @@ export async function uploadFile( resolveSymlinks ) - // The compiler assumes this is Web fetch API, but it's actually Node.js fetch API + // Buffer the gzipped tar to determine Content-Length. + // S3 presigned PUT URLs do not support Transfer-Encoding: chunked, + // and the compressed size is unknowable without actually compressing. + const chunks: Buffer[] = [] + for await (const chunk of uploadStream) { + chunks.push(Buffer.from(chunk)) + } + const body = Buffer.concat(chunks) + const res = await fetch(url, { method: 'PUT', - // @ts-expect-error - body: uploadStream, - duplex: 'half', + body, + headers: { + 'Content-Length': String(body.length), + }, }) if (!res.ok) { diff --git a/packages/js-sdk/src/template/utils.ts b/packages/js-sdk/src/template/utils.ts index 4b9cca2acf..c0bedd82f2 100644 --- a/packages/js-sdk/src/template/utils.ts +++ b/packages/js-sdk/src/template/utils.ts @@ -385,10 +385,11 @@ export async function tarFileStream( } /** - * Create a tar stream for upload using chunked transfer encoding. + * Create a tar stream for upload. * * @param fileName Glob pattern for files to include * @param fileContextPath Base directory for resolving file paths + * @param ignorePatterns Ignore patterns to exclude from the archive * @param resolveSymlinks Whether to follow symbolic links * @returns A readable stream of the gzipped tar archive */ diff --git a/packages/python-sdk/e2b/template_async/build_api.py b/packages/python-sdk/e2b/template_async/build_api.py index 7da04b1d89..52cad3f62c 100644 --- a/packages/python-sdk/e2b/template_async/build_api.py +++ b/packages/python-sdk/e2b/template_async/build_api.py @@ -111,8 +111,15 @@ async def upload_file( file_name, context_path, ignore_patterns, resolve_symlinks ) + # Use bytes with explicit Content-Length. + # S3 presigned PUT URLs do not support Transfer-Encoding: chunked. + body = tar_buffer.getvalue() client = api_client.get_async_httpx_client() - response = await client.put(url, content=tar_buffer.getvalue()) + response = await client.put( + url, + content=body, + headers={"Content-Length": str(len(body))}, + ) response.raise_for_status() except httpx.HTTPStatusError as e: raise FileUploadException(f"Failed to upload file: {e}").with_traceback( diff --git a/packages/python-sdk/e2b/template_sync/build_api.py b/packages/python-sdk/e2b/template_sync/build_api.py index 22f0be9033..59ac5ee1aa 100644 --- a/packages/python-sdk/e2b/template_sync/build_api.py +++ b/packages/python-sdk/e2b/template_sync/build_api.py @@ -110,8 +110,16 @@ def upload_file( tar_buffer = tar_file_stream( file_name, context_path, ignore_patterns, resolve_symlinks ) + + # Use bytes with explicit Content-Length. + # S3 presigned PUT URLs do not support Transfer-Encoding: chunked. + body = tar_buffer.getvalue() client = api_client.get_httpx_client() - response = client.put(url, content=tar_buffer.getvalue()) + response = client.put( + url, + content=body, + headers={"Content-Length": str(len(body))}, + ) response.raise_for_status() except httpx.HTTPStatusError as e: raise FileUploadException(f"Failed to upload file: {e}").with_traceback( From 575c2f5f0201c32ae503d02249201d642a7863e6 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Mon, 30 Mar 2026 21:02:38 +0200 Subject: [PATCH 2/7] fix: cast Pack to AsyncIterable for CLI typecheck compatibility The tar Pack type doesn't expose [Symbol.asyncIterator] in its type definitions, even though it implements it at runtime. Cast through unknown to satisfy the CLI package's stricter typecheck. Co-Authored-By: Claude Opus 4.6 --- packages/js-sdk/src/template/buildApi.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/js-sdk/src/template/buildApi.ts b/packages/js-sdk/src/template/buildApi.ts index 34a44152d4..c37fb083e4 100644 --- a/packages/js-sdk/src/template/buildApi.ts +++ b/packages/js-sdk/src/template/buildApi.ts @@ -123,8 +123,8 @@ export async function uploadFile( // S3 presigned PUT URLs do not support Transfer-Encoding: chunked, // and the compressed size is unknowable without actually compressing. const chunks: Buffer[] = [] - for await (const chunk of uploadStream) { - chunks.push(Buffer.from(chunk)) + for await (const chunk of uploadStream as unknown as AsyncIterable) { + chunks.push(chunk) } const body = Buffer.concat(chunks) From cc8dfa1110010ae17f72654d3fb31f1832a83266 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Mon, 30 Mar 2026 22:22:58 +0200 Subject: [PATCH 3/7] added changeset --- .changeset/early-hotels-peel.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/early-hotels-peel.md diff --git a/.changeset/early-hotels-peel.md b/.changeset/early-hotels-peel.md new file mode 100644 index 0000000000..826d4ed026 --- /dev/null +++ b/.changeset/early-hotels-peel.md @@ -0,0 +1,6 @@ +--- +'@e2b/python-sdk': patch +'e2b': patch +--- + +fix: set Content-Length on presigned PUT uploads for S3 compatibility From 9b84733ef6f365c73412740c2f2a2cccf590dcd0 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Mon, 30 Mar 2026 22:31:10 +0200 Subject: [PATCH 4/7] fix: scope chunks array to avoid double memory retention during upload Use a block scope so the chunks array is eligible for GC immediately after Buffer.concat, preventing peak memory from holding both the individual chunks and the concatenated buffer simultaneously. Co-Authored-By: Claude Opus 4.6 --- packages/js-sdk/src/template/buildApi.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/js-sdk/src/template/buildApi.ts b/packages/js-sdk/src/template/buildApi.ts index c37fb083e4..fc20138a76 100644 --- a/packages/js-sdk/src/template/buildApi.ts +++ b/packages/js-sdk/src/template/buildApi.ts @@ -122,11 +122,14 @@ export async function uploadFile( // Buffer the gzipped tar to determine Content-Length. // S3 presigned PUT URLs do not support Transfer-Encoding: chunked, // and the compressed size is unknowable without actually compressing. - const chunks: Buffer[] = [] - for await (const chunk of uploadStream as unknown as AsyncIterable) { - chunks.push(chunk) + let body: Buffer + { + const chunks: Buffer[] = [] + for await (const chunk of uploadStream as unknown as AsyncIterable) { + chunks.push(chunk) + } + body = Buffer.concat(chunks) } - const body = Buffer.concat(chunks) const res = await fetch(url, { method: 'PUT', From 26525964624d7d5b8743fa87e94feb4b48b8ec4d Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Mon, 30 Mar 2026 22:37:59 +0200 Subject: [PATCH 5/7] fix: run tar_file_stream in executor to avoid blocking async event loop The synchronous tar_file_stream call blocks the event loop in the async upload_file function, preventing other coroutines from running during tar creation. Offload to a thread pool via run_in_executor. Co-Authored-By: Claude Opus 4.6 --- packages/python-sdk/e2b/template_async/build_api.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/python-sdk/e2b/template_async/build_api.py b/packages/python-sdk/e2b/template_async/build_api.py index 52cad3f62c..c9694d0b4b 100644 --- a/packages/python-sdk/e2b/template_async/build_api.py +++ b/packages/python-sdk/e2b/template_async/build_api.py @@ -107,8 +107,9 @@ async def upload_file( stack_trace: Optional[TracebackType], ): try: - tar_buffer = tar_file_stream( - file_name, context_path, ignore_patterns, resolve_symlinks + loop = asyncio.get_event_loop() + tar_buffer = await loop.run_in_executor( + None, tar_file_stream, file_name, context_path, ignore_patterns, resolve_symlinks ) # Use bytes with explicit Content-Length. From 6ea52a6570b3e0afcb34ef4003d4359ea9ee565b Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Mon, 30 Mar 2026 22:48:00 +0200 Subject: [PATCH 6/7] fix: address review feedback on upload code - JS: remove explicit Content-Length header (undici auto-sets it for Buffer bodies; Content-Length is a forbidden Fetch spec header) - Python: use get_running_loop() instead of deprecated get_event_loop() Co-Authored-By: Claude Opus 4.6 --- packages/js-sdk/src/template/buildApi.ts | 9 +++------ packages/python-sdk/e2b/template_async/build_api.py | 2 +- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/js-sdk/src/template/buildApi.ts b/packages/js-sdk/src/template/buildApi.ts index fc20138a76..d474ea7b7b 100644 --- a/packages/js-sdk/src/template/buildApi.ts +++ b/packages/js-sdk/src/template/buildApi.ts @@ -119,9 +119,9 @@ export async function uploadFile( resolveSymlinks ) - // Buffer the gzipped tar to determine Content-Length. - // S3 presigned PUT URLs do not support Transfer-Encoding: chunked, - // and the compressed size is unknowable without actually compressing. + // Buffer the gzipped tar so fetch sends it with Content-Length. + // S3 presigned PUT URLs reject Transfer-Encoding: chunked (501). + // Node.js fetch (undici) auto-sets Content-Length for Buffer bodies. let body: Buffer { const chunks: Buffer[] = [] @@ -134,9 +134,6 @@ export async function uploadFile( const res = await fetch(url, { method: 'PUT', body, - headers: { - 'Content-Length': String(body.length), - }, }) if (!res.ok) { diff --git a/packages/python-sdk/e2b/template_async/build_api.py b/packages/python-sdk/e2b/template_async/build_api.py index c9694d0b4b..6bd02af46d 100644 --- a/packages/python-sdk/e2b/template_async/build_api.py +++ b/packages/python-sdk/e2b/template_async/build_api.py @@ -107,7 +107,7 @@ async def upload_file( stack_trace: Optional[TracebackType], ): try: - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() tar_buffer = await loop.run_in_executor( None, tar_file_stream, file_name, context_path, ignore_patterns, resolve_symlinks ) From 4de627c267b03f55238c1d540da4a418d37497fd Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:11:31 +0200 Subject: [PATCH 7/7] fix: format build_api.py with ruff Co-Authored-By: Claude Opus 4.6 --- packages/python-sdk/e2b/template_async/build_api.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/python-sdk/e2b/template_async/build_api.py b/packages/python-sdk/e2b/template_async/build_api.py index 6bd02af46d..0930ff5d0a 100644 --- a/packages/python-sdk/e2b/template_async/build_api.py +++ b/packages/python-sdk/e2b/template_async/build_api.py @@ -109,7 +109,12 @@ async def upload_file( try: loop = asyncio.get_running_loop() tar_buffer = await loop.run_in_executor( - None, tar_file_stream, file_name, context_path, ignore_patterns, resolve_symlinks + None, + tar_file_stream, + file_name, + context_path, + ignore_patterns, + resolve_symlinks, ) # Use bytes with explicit Content-Length.