Skip to content
6 changes: 6 additions & 0 deletions .changeset/azure-template-upload-headers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"e2b": patch
"@e2b/python-sdk": patch
---

Apply the request headers the API returns with a template layer-file upload link. Azure Blob Storage requires `x-ms-blob-type` on the upload request, which its signed URL cannot carry, so `COPY` instructions failed on Azure-backed clusters. GCS- and S3-backed clusters return no headers and are unaffected.
13 changes: 11 additions & 2 deletions packages/js-sdk/src/template/buildApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ export async function uploadFile(
fileName: string
fileContextPath: string
url: string
headers?: Record<string, string>
ignorePatterns: string[]
resolveSymlinks: boolean
gzip: boolean
Expand All @@ -128,6 +129,7 @@ export async function uploadFile(
const {
fileName,
url,
headers,
fileContextPath,
ignorePatterns,
resolveSymlinks,
Expand All @@ -154,7 +156,7 @@ export async function uploadFile(
abortOpts?.signal
)

const res = await putFileStream(url, tar.path, tar.size, signal)
const res = await putFileStream(url, tar.path, tar.size, signal, headers)

if (!res.ok) {
throw new FileUploadError(
Expand All @@ -176,7 +178,8 @@ async function putFileStream(
url: string,
filePath: string,
size: number,
signal: AbortSignal | undefined
signal: AbortSignal | undefined,
headers?: Record<string, string>
): Promise<{ ok: boolean; statusText: string }> {
// Prefer undici's fetch: it honors the explicit Content-Length on stream
// bodies on every runtime, while Deno's native fetch ignores the header and
Expand All @@ -192,7 +195,13 @@ async function putFileStream(
body: stream.Readable.toWeb(
fs.createReadStream(filePath)
) as ReadableStream,
// API-returned headers applied as given (Azure needs x-ms-blob-type, which a SAS cannot carry); Content-Length stays ours, dropped case-insensitively since fetch header names are not case-sensitive.
headers: {
...Object.fromEntries(
Object.entries(headers ?? {}).filter(
([name]) => name.toLowerCase() !== 'content-length'
)
),
'Content-Length': size.toString(),
Comment on lines 199 to 205

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize headers before overriding Content-Length

When the API returns this header with any casing other than exactly Content-Length (for example, content-length), the object retains both keys because JavaScript property names are case-sensitive, while Fetch header names are not. Undici combines the values (such as 1, 123), causing the streamed PUT to fail with a content-length mismatch; the added test only covers the exact-case spelling. Delete or replace API-provided Content-Length case-insensitively before setting the archive size.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in bfc487d — API-sent Content-Length is now stripped case-insensitively before ours is set, in JS and both Python variants (same hole); the forcing tests now pass lowercase content-length to pin the behavior.

},
// Streaming request bodies require half-duplex mode.
Expand Down
3 changes: 2 additions & 1 deletion packages/js-sdk/src/template/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1112,7 +1112,7 @@ export class TemplateBase
stackTrace = this.stackTraces[index + 1]
}

const { present, url } = await getFileUploadLink(
const { present, url, headers } = await getFileUploadLink(
client,
{
templateID,
Expand All @@ -1131,6 +1131,7 @@ export class TemplateBase
fileName: src,
fileContextPath: this.fileContextPath.toString(),
url,
headers,
ignorePatterns: [
...this.fileIgnorePatterns,
...readDockerignore(this.fileContextPath.toString()),
Expand Down
39 changes: 39 additions & 0 deletions packages/js-sdk/tests/template/uploadFile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,5 +73,44 @@ describe('uploadFile transfer encoding', () => {
// Content-Type (e.g. inferred from the archive's file extension) makes
// the storage backend reject the upload with 403 Forbidden.
expect(capturedHeaders['content-type']).toBeUndefined()

// S3/GCS presigned PUTs sign the header set — the upload must add nothing the API did not ask for.
expect(capturedHeaders['x-ms-blob-type']).toBeUndefined()
})

test('sends the headers the API returned with the upload link', async () => {
await uploadFile(
{
fileName: '*.txt',
fileContextPath: testDir,
url: baseUrl,
headers: { 'x-ms-blob-type': 'BlockBlob' },
ignorePatterns: [],
resolveSymlinks: false,
gzip: true,
},
undefined
)

// Azure's Put Blob needs a request header a SAS cannot carry, so the API hands it back instead.
expect(capturedHeaders['x-ms-blob-type']).toBe('BlockBlob')
})

test('keeps its own Content-Length when the API returns one', async () => {
await uploadFile(
{
fileName: '*.txt',
fileContextPath: testDir,
url: baseUrl,
// lowercase on purpose: header names are case-insensitive, object keys are not
headers: { 'content-length': '1' },
ignorePatterns: [],
resolveSymlinks: false,
gzip: true,
},
undefined
)

expect(Number(capturedHeaders['content-length'])).toBe(capturedBodyLength)
})
})
18 changes: 12 additions & 6 deletions packages/python-sdk/e2b/template_async/build_api.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import asyncio
import os
from types import TracebackType
from typing import Callable, Optional, List, Union
from typing import Callable, Dict, Optional, List, Union

import httpx
from pyqwest import HTTPTransport
Expand Down Expand Up @@ -115,6 +115,8 @@ async def upload_file(
resolve_symlinks: bool,
gzip: bool,
stack_trace: Optional[TracebackType],
*,
headers: Optional[Dict[str, str]] = None,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

T-3a: same as the sync variant — make the new optional keyword-only.

Suggested change
headers: Optional[Dict[str, str]] = None,
*,
headers: Optional[Dict[str, str]] = None,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 105242e — sync now merges API headers under its own Content-Length (os.fstat size, mirroring async) with a forcing test in both variants, and headers is keyword-only in both signatures. The Unset note applied too: explicit isinstance in both main.py loops.

request_timeout: Optional[float] = None,
):
# Uploading a large build-context archive can take far longer than the 60s
Expand Down Expand Up @@ -152,14 +154,18 @@ async def upload_file(
)
),
) as client:
# Stream the archive from disk via an async iterator. The
# explicit Content-Length suppresses chunked transfer
# encoding, which S3 presigned URLs reject; reqwest keeps the
# Content-Length framing for the streamed body.
# API-returned headers applied as given, but Content-Length stays ours — explicit so S3 presigned URLs see no chunked encoding.
response = await client.put(
url,
content=aiter_io_chunks(tar_file),
headers={"Content-Length": str(size)},
headers={
**{
k: v
for k, v in (headers or {}).items()
if k.lower() != "content-length"
},
"Content-Length": str(size),
},
)
response.raise_for_status()
finally:
Expand Down
6 changes: 6 additions & 0 deletions packages/python-sdk/e2b/template_async/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from typing_extensions import Unpack

from e2b.api.client.client import AuthenticatedClient
from e2b.api.client.types import Unset
from e2b.connection_config import ApiParams, ConnectionConfig
from e2b.template.consts import GZIP, RESOLVE_SYMLINKS
from e2b.template.logger import LogEntry, LogEntryEnd, LogEntryStart
Expand Down Expand Up @@ -137,6 +138,11 @@ async def _build(
resolve_symlinks,
gzip,
stack_trace,
headers=(
file_info.headers.to_dict()
if not isinstance(file_info.headers, Unset)
else None
),
request_timeout=request_timeout,
)
if on_build_logs:
Expand Down
24 changes: 18 additions & 6 deletions packages/python-sdk/e2b/template_sync/build_api.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import os
import time
from types import TracebackType
from typing import Callable, Optional, List, Union
from typing import Callable, Dict, Optional, List, Union

import httpx
from pyqwest import SyncHTTPTransport
Expand Down Expand Up @@ -113,6 +114,8 @@ def upload_file(
resolve_symlinks: bool,
gzip: bool,
stack_trace: Optional[TracebackType],
*,
headers: Optional[Dict[str, str]] = None,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

T-3a (optionals are keyword-only, enforced by a bare *): a defaulted parameter without * is still positional — this both inserts a new positional before request_timeout and lets callers bind headers by position. Both call sites already pass it by keyword, so:

Suggested change
headers: Optional[Dict[str, str]] = None,
*,
headers: Optional[Dict[str, str]] = None,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 105242e — sync now merges API headers under its own Content-Length (os.fstat size, mirroring async) with a forcing test in both variants, and headers is keyword-only in both signatures. The Unset note applied too: explicit isinstance in both main.py loops.

request_timeout: Optional[float] = None,
):
# Uploading a large build-context archive can take far longer than the 60s
Expand All @@ -127,6 +130,7 @@ def upload_file(
tar_file = tar_file_stream(
file_name, context_path, ignore_patterns, resolve_symlinks, gzip
)
size = os.fstat(tar_file.fileno()).st_size
try:
# Through the pyqwest adapter the upload timeout is a
# whole-request deadline for the entire transfer, not a per-write
Expand All @@ -148,11 +152,19 @@ def upload_file(
)
),
) as client:
# httpx streams the archive from disk in chunks and sets
# Content-Length from the file size—S3 presigned URLs reject
# chunked transfer encoding, and reqwest keeps the
# Content-Length framing for the streamed body.
response = client.put(url, content=tar_file)
# API-returned headers applied as given, but Content-Length stays ours — explicit so S3 presigned URLs see no chunked encoding.
response = client.put(
url,
content=tar_file,
headers={
**{
k: v
for k, v in (headers or {}).items()
if k.lower() != "content-length"
},
"Content-Length": str(size),
},
)
response.raise_for_status()
finally:
# Closing the spooled temp file is best-effort: a failure here
Expand Down
6 changes: 6 additions & 0 deletions packages/python-sdk/e2b/template_sync/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from typing_extensions import Unpack

from e2b.api.client.client import AuthenticatedClient
from e2b.api.client.types import Unset
from e2b.connection_config import ApiParams, ConnectionConfig

from e2b.api.client_sync import get_api_client
Expand Down Expand Up @@ -137,6 +138,11 @@ def _build(
resolve_symlinks,
gzip,
stack_trace,
headers=(
file_info.headers.to_dict()
if not isinstance(file_info.headers, Unset)
else None
),
request_timeout=request_timeout,
)
if on_build_logs:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -240,3 +240,88 @@ def failing_close_stream(*args, **kwargs):
thread.join(timeout=5)

assert state["headers"] is not None


async def test_upload_file_sends_the_headers_the_api_returned(tmp_path):
# Azure's Put Blob needs a request header a SAS cannot carry, so the API hands it back with the upload link.
(tmp_path / "hello.txt").write_text("hello world")

server, thread, state = _make_server()
host, port = server.server_address

try:
client = AuthenticatedClient(base_url="http://test", token="test")
await upload_file(
api_client=client,
file_name="*.txt",
context_path=str(tmp_path),
url=f"http://{host}:{port}/upload",
ignore_patterns=[],
resolve_symlinks=False,
gzip=True,
stack_trace=None,
headers={"x-ms-blob-type": "BlockBlob"},
)
finally:
server.shutdown()
server.server_close()
thread.join(timeout=5)

assert state["headers"]["x-ms-blob-type"] == "BlockBlob"


async def test_upload_file_adds_no_headers_when_the_api_returns_none(tmp_path):
# S3/GCS presigned PUTs sign the header set — the upload must add nothing the API did not ask for.
(tmp_path / "hello.txt").write_text("hello world")

server, thread, state = _make_server()
host, port = server.server_address

try:
client = AuthenticatedClient(base_url="http://test", token="test")
await upload_file(
api_client=client,
file_name="*.txt",
context_path=str(tmp_path),
url=f"http://{host}:{port}/upload",
ignore_patterns=[],
resolve_symlinks=False,
gzip=True,
stack_trace=None,
)
finally:
server.shutdown()
server.server_close()
thread.join(timeout=5)

assert "x-ms-blob-type" not in state["headers"]


async def test_upload_file_keeps_its_own_content_length(tmp_path):
# An API-returned Content-Length must never override the real archive size.
(tmp_path / "hello.txt").write_text("hello world")

server, thread, state = _make_server()
host, port = server.server_address

try:
client = AuthenticatedClient(base_url="http://test", token="test")
await upload_file(
api_client=client,
file_name="*.txt",
context_path=str(tmp_path),
url=f"http://{host}:{port}/upload",
ignore_patterns=[],
resolve_symlinks=False,
gzip=True,
stack_trace=None,
# lowercase on purpose: header names are case-insensitive, dict keys are not
headers={"x-ms-blob-type": "BlockBlob", "content-length": "1"},
)
finally:
server.shutdown()
server.server_close()
thread.join(timeout=5)

assert state["body_length"] > 1
assert int(state["headers"]["content-length"]) == state["body_length"]
Loading
Loading