Skip to content

fix(sdk): apply the upload headers the API returns with a file upload link - #1870

Merged
michalsuba-e2b merged 7 commits into
mainfrom
feat/azure-upload-headers
Sep 15, 2026
Merged

michalsuba-e2b merged 7 commits into
mainfrom
feat/azure-upload-headers

Conversation

@michalsuba-e2b

@michalsuba-e2b michalsuba-e2b commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

The API now returns request headers with a file-upload link (e2b-dev/belt#3308, moved from e2b-dev/runtime#3634 — Azure Put Blob needs x-ms-blob-type, which its SAS cannot carry); both SDKs apply them on the upload PUT. Header-less providers (S3/GCS/fs) get byte-identical requests, so nothing changes off Azure.

  • JS: getFileUploadLink returns headers; putFileStream merges them under our own Content-Length, stripping any API-sent Content-Length case-insensitively.
  • Python sync + async: upload_file takes keyword-only headers, merged the same way (sync mirrored to async per review).
  • Tests: header pass-through, no-headers guard, and Content-Length-stays-ours (lowercase spelling to pin case-insensitivity), in JS and both Python variants; live suites (GCS production path) green.
  • Validated e2e at head against an Azure BYOC env (miso9) through staging api + edge: multi-COPY build green with uploads landing (cache miss) and a zero-upload green rerun (cache hit); earlier direct probes: headers applied → 201, stripped → MissingRequiredHeader.
  • spec/runtime-ref pins runtime main (756512ca8), which carries the merged contract (e2b-dev/belt#3308).

Changesets: one patch per SDK.

Sponsor: @michalsuba-e2b

🤖 Generated with Claude Code

… link

Azure Blob Storage requires the request header x-ms-blob-type on Put Blob,
which a signed URL cannot carry, so every template build with a COPY
instruction failed on Azure-backed clusters. The API now returns the headers
alongside the upload URL; both SDKs send them verbatim on the PUT and keep
their own Content-Length.

GCS- and S3-backed clusters return no headers, so their presigned PUTs go out
with the same header set as before — their signatures cover the header list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cla-bot cla-bot Bot added the cla-signed label Sep 11, 2026
@changeset-bot

changeset-bot Bot commented Sep 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 5085b81

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
e2b Patch
@e2b/python-sdk Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Package Artifacts

Built from e6f575b. Download artifacts from this workflow run.

JS SDK (e2b@2.49.2-feat-azure-upload-headers.0):

npm install ./e2b-2.49.2-feat-azure-upload-headers.0.tgz

CLI (@e2b/cli@2.19.1-feat-azure-upload-headers.0):

npm install ./e2b-cli-2.19.1-feat-azure-upload-headers.0.tgz

Code Interpreter JS SDK (@e2b/code-interpreter@2.8.1-feat-azure-upload-headers.0):

npm install ./e2b-code-interpreter-2.8.1-feat-azure-upload-headers.0.tgz

Desktop JS SDK (@e2b/desktop@2.4.1-feat-azure-upload-headers.0):

npm install ./e2b-desktop-2.4.1-feat-azure-upload-headers.0.tgz

Python SDK (e2b==2.49.1+feat.azure.upload.headers):

pip install ./e2b-2.49.1+feat.azure.upload.headers-py3-none-any.whl

Code Interpreter Python SDK (e2b-code-interpreter==2.10.0+feat.azure.upload.headers):

pip install ./e2b_code_interpreter-2.10.0+feat.azure.upload.headers-py3-none-any.whl

Desktop Python SDK (e2b-desktop==2.5.0+feat.azure.upload.headers):

pip install ./e2b_desktop-2.5.0+feat.azure.upload.headers-py3-none-any.whl

@devin-ai-integration devin-ai-integration Bot left a comment

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.

TASTE.md review of the SDK changes (generated clients, spec, tests and changesets excluded — only buildApi.ts, template/index.ts, template_{sync,async}/build_api.py, template_{sync,async}/main.py were judged).

Checked: parity (T-1, T-2), API shape / keyword-only optionals (T-3, T-3a), generated-type boundary (T-18), timeouts & signal threading (T-46), comments/docstrings (T-69).

2 violations, both in the Python SDK:

  1. T-2 / T-1 — sync and async upload_file diverge. The async mirror merges the API headers under its own Content-Length ({**(headers or {}), "Content-Length": str(size)}) and the JS side has a test for exactly that, but the sync mirror passes headers straight through, so an API-returned Content-Length would win. Same method, same semantics is the rule; the sync variant needs the same override and a matching test_upload_file_keeps_its_own_content_length case.
  2. T-3a — new optional headers is positional. It was inserted before request_timeout without a *, so it both re-orders an existing optional and lets callers bind it by position. Both call sites already pass it by keyword, so a bare * costs nothing here (sync + async).

Not tied to a diff line: JS uploadFile/putFileStream correctly keep Content-Length ours and thread signal (T-46) — no issues found there. file_info.headers.to_dict() if file_info.headers else None in both main.py files relies on Unset.__bool__; it's internal plumbing so T-18 doesn't strictly apply, but if not isinstance(file_info.headers, Unset) would be the explicit spelling used elsewhere in the generated client.

response = client.put(url, content=tar_file)
# Headers the API asked for, applied as given (Azure's Put
# Blob requires x-ms-blob-type, which its SAS cannot carry).
response = client.put(url, content=tar_file, headers=headers)

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-2 (sync and async mirrors share the same semantics) / T-1: the async upload_file merges these headers under its own Content-Length and the JS side tests "keeps its own Content-Length when the API returns one", but here headers is passed through verbatim, so an API-returned Content-Length would override httpx's correct one. Mirror the async form:

Suggested change
response = client.put(url, content=tar_file, headers=headers)
size = os.fstat(tar_file.fileno()).st_size
response = client.put(
url,
content=tar_file,
headers={**(headers or {}), "Content-Length": str(size)},
)

(import os needed, as in the async module)

(and add the sync counterpart of the JS keeps its own Content-Length test).

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.

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.

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.

@michalsuba-e2b

Copy link
Copy Markdown
Contributor Author

CI attribution, for the reviewer — and the live suites raise the evidence bar on the part that matters here:

The changed upload path passes end to end against the real API, in both SDKs. tests/template/build.test.ts (4 tests, 98.7s) exercises .copy(..., { forceUpload: true }) through the new putFileStream and is green on node, bun, deno and cloudflare, production and staging. test_build_template_with_symlinks and test_build_template_with_resolve_symlinks do the same through upload_file and pass in both sync and async. Generated files passes, so the spec/runtime-ref bump and make codegen output agree. GCS returns no headers, which is exactly the regression vector.

Python SDK (4 jobs) — one failure, test_build_template_with_skip_cache, Timeout (>180.0s) from pytest-timeout (1 failed / 1174 passed). Its template is .skip_cache().from_image("ubuntu:22.04") with no COPY, so the upload loop skips it and upload_file is never called. A forced uncached rebuild is the slowest test in the suite.

Code Interpreter JS SDK - nodetests/cwd.test.ts > cwd r, SandboxError: 500 Internal Server Error after 38s against the live API. Code-interpreter does not touch template file upload.

@michalsuba-e2b

Copy link
Copy Markdown
Contributor Author

Live validation of the header-merge path against a real Azure BYOC env (miso9): uploadFile from this branch with server-returned headers → 201 + blob landed; with headers absent → FileUploadError (Azure MissingRequiredHeader). Details: https://linear.app/e2b/issue/BYOC-228#comment-259c9df2

michalsuba-e2b and others added 2 commits September 14, 2026 10:08
…headers keyword-only

Review fixes: the sync upload_file now mirrors async — API-returned headers
merge under our own Content-Length (forcing test added in both variants);
headers is keyword-only in both signatures; the Unset check in main.py is the
explicit isinstance spelling. Comments compressed to one line each.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… regen

Mechanical: make codegen after the runtime branch merged its main (drags in
the V1 template-build endpoint removal). Re-point at the merge commit before
undrafting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@michalsuba-e2b
michalsuba-e2b marked this pull request as ready for review September 14, 2026 08:42

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5d27b1f295

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines 199 to 201
headers: {
...headers,
'Content-Length': size.toString(),

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.

…ting ours

Review finding: fetch header names are case-insensitive but object/dict keys
are not, so an API-returned lowercase content-length survived the spread and
undici/httpx would send both values. Filter it out in JS and both Python
variants; the keeps-its-own-Content-Length tests now use the lowercase
spelling to force the path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I reviewed this PR and did not find any bugs. Because it touches build-upload networking logic across the JS and Python (sync + async) SDKs and regenerates the API client from a bumped runtime-spec pin, a human look would still be worthwhile.

What was reviewed:

  • Header pass-through and Content-Length precedence in JS putFileStream and Python sync/async upload_file — API-supplied headers are merged first, then Content-Length is forced, matching the PR's stated behavior.
  • The sync build_api.py fix (explicit os.fstat size + merged headers) that appears to resolve the earlier inline review comments on that file and its async counterpart.
  • Did not independently re-verify the full spec/openapi.yml regeneration for byte-for-byte fidelity to a make codegen run.
Extended reasoning...

Overview

This PR threads API-returned upload headers (needed for Azure Blob Storage's x-ms-blob-type requirement) through both the JS SDK (buildApi.ts, index.ts) and the Python SDK (sync and async build_api.py/main.py), and regenerates the OpenAPI client/spec from a bumped spec/runtime-ref pin, which also removes deprecated v1/v2 template endpoints and adds unrelated scheduling fields. Tests were added for header pass-through and Content-Length precedence in both languages, and two changesets are present per CLAUDE.md's requirement.

Security risks

The headers merged into the PUT request originate from the trusted first-party API response, not user input, so this isn't an injection vector in the traditional sense. The main risk class is subtler: header-name case handling and null-vs-Unset deserialization edge cases in the generated Python model, both of which were already surfaced as candidates and are not something I found new evidence to escalate beyond what was already considered.

Level of scrutiny

This warrants more than a rubber-stamp: it's a cross-language SDK change (JS + Python sync/async, properly mirrored) that alters a real network code path (the build-context upload PUT), plus a spec regeneration that alters generated client surface across several files. It's not a one-line config tweak, and prior reviewer feedback (devin-ai-integration inline comments on build_api.py) was substantive enough that the author pushed follow-up commits specifically to address it, which I confirmed by diffing before/after (the sync path now computes size via os.fstat and explicitly merges headers alongside a forced Content-Length, mirroring the async path).

Other factors

The bug-hunting run exited via dry_streak (ran until no new findings), and reported no bugs this run; the previously-flagged candidates (spec pin pointing at an unmerged branch head, header case-sensitivity in the JS/Python merge, and null-vs-Unset in the generated model) were investigated and not escalated as findings. The unmerged-branch-pin point is also already called out by the PR author themselves as a known pre-merge TODO, so repeating it adds nothing. Given the change's breadth across two SDKs and a regenerated client, but the absence of any new confirmed defect, a defer with a brief "no bugs found, human look still useful" note is the right level of signal here rather than an outright approval or silence.

This review covers commit 5d27b1f, which is no longer the latest commit on this pull request; later commits are not covered by it.

belt#3308 merged and synced out; the pin now references the contract on main
(drags in the 429 declarations from the same window).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Sep 14, 2026

Copy link
Copy Markdown

BYOC-228

@tomassrnka

Copy link
Copy Markdown
Member

We landed on the same fix independently in #1876 (now closed in favour of this one, which has the live Azure evidence). Three deltas from that branch, take or leave each:

  1. Also drop transfer-encoding from the API map, case-insensitively, next to content-length. Verified on the wire with pyqwest 0.10.0 / hyper: a transfer-encoding: chunked from the map is forwarded and produces a chunked body next to the explicit Content-Length, which S3 presigned PUTs reject (the e2b#1243 class).
  2. Keep the cause in the JS wrap. uploadFile catches and throws FileUploadError(Failed to upload file: ${error}), so an undici rejection surfaces as a bare TypeError: fetch failed; appending error.cause?.message gives … (invalid content-length header).
  3. One end-to-end JS test through Template.build (msw for the API, a local node:http PUT target) catches the index.ts destructuring half on its own — a buildApi-level test stays green with index.ts still dropping headers. Branch: fix/template-upload-headers, file packages/js-sdk/tests/template/uploadHeaders.test.ts.

@michalsuba-e2b

Copy link
Copy Markdown
Contributor Author

End-to-end validation at this exact head (1cfbb7c) through the full production path — SDK → staging api v0.14.202609150507-ce3393967a2 (carries belt#3308) → edge → orchestrator-ee v0.16.202609141726-72a060fc1ac on an Azure BYOC env (miso9), 2026-09-15 06:01 UTC:

  • Cache miss (multi-COPY template, fresh file content): build green in 23s; exactly 2 files/*.tar blobs landed in the env's fc-build-cache at 06:01:34Z — one per COPY, PUT with the API-returned x-ms-blob-type merged under our Content-Length.
  • Cache hit (identical files, immediate rerun): build green in 13s, zero new blobs — present=true skipped both uploads.

Both lanes exercised with the rebuilt SDK snapshot including the case-insensitive Content-Length strip. Nothing further blocks merge.

@michalsuba-e2b

Copy link
Copy Markdown
Contributor Author

E2E re-validated at the merge commit (8f4c744 — SDK rebuilt from it, fresh install verified to carry the case-insensitive Content-Length strip), 2026-09-15 06:11–06:12 UTC against miso9 through staging api + edge:

  • Cache miss: green in 29s, exactly 2 files/*.tar blobs landed at 06:11:57Z (one per COPY).
  • Cache hit (identical files): green in 12s, zero new blobs — uploads skipped via present=true.

@mishushakov mishushakov left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

feel free to proceed

Comment thread .changeset/azure-template-upload-headers-python.md Outdated
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@michalsuba-e2b
michalsuba-e2b enabled auto-merge (squash) September 15, 2026 11:52
@michalsuba-e2b
michalsuba-e2b merged commit 956e3ab into main Sep 15, 2026
136 of 145 checks passed
@michalsuba-e2b
michalsuba-e2b deleted the feat/azure-upload-headers branch September 15, 2026 11:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants