Skip to content

fix(proxy): CORS on edge-synthesized sandbox errors - #3389

Open
mishushakov wants to merge 2 commits into
mainfrom
brussels
Open

fix(proxy): CORS on edge-synthesized sandbox errors#3389
mishushakov wants to merge 2 commits into
mainfrom
brussels

Conversation

@mishushakov

Copy link
Copy Markdown
Member

Sibling of #3388 — same class of bug (a CORS gap that makes an SDK feature silently unusable in browsers), but in the sandbox proxy rather than the API. Tracked SDK-side as SDK-294; surfaced by the new browser test leg (SDK-292 / e2b#1609), which currently skips 13 tests because of it.

Problem

When a sandbox is running, the CORS headers on its host come from envd, inside the sandbox. When it is paused or killed there is no envd to answer, so the proxy synthesizes the error — and that response carried no Access-Control-* headers at all. Measured against production:

sandbox state preflight (OPTIONS) actual (GET)
running 204 · ACAO: * · ACAH: authorization,user-agent 204 · ACAO: *
paused / killed 502 · no CORS headers 502 · no CORS headers

CORS support was therefore coupled to the sandbox being alive. For anything that probes liveness this is inverted: the one answer the probe exists to deliver is the one a browser cannot receive. Sandbox.isRunning() maps the 502 to false, but in a browser fetch rejects before there is a status to read, so a bare TypeError: Failed to fetch reaches the caller, indistinguishable from being offline. Knock-on, checkSandboxHealth can only return true or undefined in a browser, so 24 SDK call sites downgrade an actionable TimeoutError("The sandbox was killed…") to a generic SandboxError("network error").

Adding the headers to the 502 alone would not have fixed it. Every envd request carries E2b-Sandbox-Id and E2b-Sandbox-Port (plus X-Access-Token for secured sandboxes) — non-safelisted, so a browser always preflights. Per the Fetch standard a preflight response must have an ok status or the browser returns a network error regardless of which headers it carries. Hence two parts.

Changes

New packages/shared/pkg/proxy/cors/, used by both client-proxy and the edge:

  • Answer preflights with 204 on paths that would otherwise answer with an error status — in handler() when getDestination fails, and in the pool's ErrorHandler, which owns port_closed and the unreachable-sandbox 502. A bare OPTIONS (no Access-Control-Request-Method) is not a preflight and still falls through to the error.
  • Access-Control-Allow-Origin: * (matching what envd sends) on the synthesized responses. TemplatedError.HandleError is the single choke point for all eight error templates, so it covers sandbox_not_found, port_closed, sandbox_still_transitioning, sandbox_resume_permission_denied, sandbox_too_many_connections, team_sandbox_limit and both traffic_access_token_errors at once. The raw http.Error paths — missing header, invalid host/sandbox-ID/port, and the unreachable-sandbox ones in the pool — go through the new cors.Error.

The healthy path is untouched: envd keeps answering its own preflights, and the proxy must not add a second Access-Control-Allow-Origin (a browser rejects two values as readily as none) — there is a test for that.

Also fixes content negotiation, found while investigating. template.go chose HTML over JSON by User-Agent sniffing, but a fetch() from page scripts carries the browser's User-Agent and cannot override it (UA is a forbidden header name), so an SDK call from a browser got the HTML error page where it expects JSON, and Accept was ignored entirely:

no UA                                -> 502 application/json
chrome UA                            -> 502 text/html
chrome UA + Accept: application/json -> 502 text/html   <- Accept ignored

Intent now decides first: JSON when Accept prefers it, or when Sec-Fetch-Mode / X-Requested-With mark the request as script-initiated. UA sniffing stays as the fallback, where it only catches genuine top-level navigations — which is what the HTML pages are for.

Tests

  • packages/shared/pkg/proxy/cors/cors_test.go — the helpers in isolation.
  • packages/shared/pkg/proxy/cors_test.go — handler-level, plus socket-level tests that reproduce the browser's preflight-then-request exchange against a missing and an unreachable sandbox, and assert the live-sandbox path does not get a duplicated header.
  • packages/shared/pkg/proxy/template/template_test.go — a table over all eight constructors asserting the header in both HTML and JSON modes, so a newly added template cannot silently miss it, plus the content-negotiation matrix.
  • tests/integration/.../sandbox_not_found_test.go — extended: a stopped sandbox's preflight is 2xx with the echoed headers and the follow-up GET is 502 with Access-Control-Allow-Origin.

Both new behaviors were checked to have teeth by reverting the fix and watching the tests fail. Verified with go test -race ./packages/shared/pkg/proxy/..., make fmt and make lint (the one remaining lint failure, cmd/create-build/proxyport_test.go, is pre-existing on main and platform-related).

Rollout

No SDK release is coupled to this; the flag removal is test-only. Deploy order: infra fix → staging → production → drop canObserveStoppedSandbox from the JS SDK's tests/setup.ts, which should leave the 13 currently-skipped browser tests green with no SDK change. The browser CI leg only runs against production, so those stay red until the production rollout.

🤖 Generated with Claude Code

@cursor

cursor Bot commented Jul 24, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Touches all browser-facing proxy error and preflight paths; behavior is well covered by tests but mis-handling could still break cross-origin SDK calls or error parsing.

Overview
When a sandbox is not running, the proxy answers instead of envd and those responses lacked CORS, so browsers turned 502s into opaque network errors and SDK liveness checks could not read stopped-sandbox status. The change introduces shared CORS helpers that return 204 for real preflights on paths that would otherwise error, sets Access-Control-Allow-Origin: * on synthesized errors (templates and plain error paths), and leaves healthy proxied traffic unchanged so envd’s headers are not duplicated. Templated errors now prefer JSON when Accept or script-initiated signals (Sec-Fetch-Mode, X-Requested-With) indicate a fetch rather than choosing HTML from User-Agent alone.

Reviewed by Cursor Bugbot for commit 3704240. Bugbot is set up for automated code reviews on this repo. Configure here.

@codecov

codecov Bot commented Jul 24, 2026

Copy link
Copy Markdown

❌ 6 Tests Failed:

Tests completed Failed Passed Skipped
3564 6 3558 7
View the top 3 failed test(s) by shortest run time
github.com/e2b-dev/infra/tests/integration/internal/tests/api/sandboxes::TestGPGKeyserverWorks
Stack Traces | 0s run time
=== RUN   TestGPGKeyserverWorks
=== PAUSE TestGPGKeyserverWorks
=== CONT  TestGPGKeyserverWorks
--- FAIL: TestGPGKeyserverWorks (0.00s)
github.com/e2b-dev/infra/tests/integration/internal/tests/envd::TestListDir/depth_1_lists_root_directory
Stack Traces | 0.01s run time
=== RUN   TestListDir/depth_1_lists_root_directory
=== PAUSE TestListDir/depth_1_lists_root_directory
=== CONT  TestListDir/depth_1_lists_root_directory
    filesystem_test.go:96: 
        	Error Trace:	.../tests/envd/filesystem_test.go:96
        	Error:      	Received unexpected error:
        	            	unavailable: 502 Bad Gateway
        	Test:       	TestListDir/depth_1_lists_root_directory
--- FAIL: TestListDir/depth_1_lists_root_directory (0.01s)
github.com/e2b-dev/infra/tests/integration/internal/tests/envd::TestListDir/depth_2_lists_first_level_of_subdirectories_(in_this_case_the_root_directory)
Stack Traces | 0.01s run time
=== RUN   TestListDir/depth_2_lists_first_level_of_subdirectories_(in_this_case_the_root_directory)
=== PAUSE TestListDir/depth_2_lists_first_level_of_subdirectories_(in_this_case_the_root_directory)
=== CONT  TestListDir/depth_2_lists_first_level_of_subdirectories_(in_this_case_the_root_directory)
    filesystem_test.go:96: 
        	Error Trace:	.../tests/envd/filesystem_test.go:96
        	Error:      	Received unexpected error:
        	            	unavailable: 502 Bad Gateway
        	Test:       	TestListDir/depth_2_lists_first_level_of_subdirectories_(in_this_case_the_root_directory)
--- FAIL: TestListDir/depth_2_lists_first_level_of_subdirectories_(in_this_case_the_root_directory) (0.01s)
github.com/e2b-dev/infra/tests/integration/internal/tests/envd::TestListDir/depth_0_lists_only_root_directory
Stack Traces | 0.02s run time
=== RUN   TestListDir/depth_0_lists_only_root_directory
=== PAUSE TestListDir/depth_0_lists_only_root_directory
=== CONT  TestListDir/depth_0_lists_only_root_directory
    filesystem_test.go:96: 
        	Error Trace:	.../tests/envd/filesystem_test.go:96
        	Error:      	Received unexpected error:
        	            	unavailable: 502 Bad Gateway
        	Test:       	TestListDir/depth_0_lists_only_root_directory
--- FAIL: TestListDir/depth_0_lists_only_root_directory (0.02s)
github.com/e2b-dev/infra/tests/integration/internal/tests/envd::TestListDir
Stack Traces | 0.65s run time
=== RUN   TestListDir
=== PAUSE TestListDir
=== CONT  TestListDir
--- FAIL: TestListDir (0.65s)
github.com/e2b-dev/infra/tests/integration/internal/tests/api/sandboxes::TestGPGKeyserverWorks/without_network_config
Stack Traces | 2.4s run time
=== RUN   TestGPGKeyserverWorks/without_network_config
=== PAUSE TestGPGKeyserverWorks/without_network_config
=== CONT  TestGPGKeyserverWorks/without_network_config
    sandbox_network_out_test.go:1002: Testing GPG keyserver connection to hkp://keyserver.ubuntu.com...
    sandbox_network_out_test.go:1003: Command [gpg] output: event:{start:{pid:1085}}
    sandbox_network_out_test.go:1003: Command [gpg] output: event:{data:{stderr:"gpg: directory '............/home/user/.gnupg' created\n"}}
    sandbox_network_out_test.go:1003: Command [gpg] output: event:{data:{stderr:"gpg: keybox '............/home/user/.gnupg/pubring.kbx' created\n"}}
    sandbox_network_out_test.go:1003: Command [gpg] output: event:{data:{stderr:"gpg: keyserver receive failed: No data\n"}}
    sandbox_network_out_test.go:1003: Command [gpg] output: event:{end:{exit_code:2  exited:true  status:"exit status 2"  error:"exit status 2"}}
    sandbox_network_out_test.go:1006: 
        	Error Trace:	.../api/sandboxes/sandbox_network_out_test.go:1006
        	Error:      	Received unexpected error:
        	            	command gpg in sandbox ibsautanul2i8ade7xqf0 failed with exit code 2
        	Test:       	TestGPGKeyserverWorks/without_network_config
        	Messages:   	Expected GPG keyserver command to succeed, got error: command gpg in sandbox ibsautanul2i8ade7xqf0 failed with exit code 2, output: gpg: directory '............/home/user/.gnupg' created
        	            	gpg: keybox '............/home/user/.gnupg/pubring.kbx' created
        	            	gpg: keyserver receive failed: No data
--- FAIL: TestGPGKeyserverWorks/without_network_config (2.40s)
github.com/e2b-dev/infra/tests/integration/internal/tests/api/templates::TestTemplateBuildFuseConfiguration
Stack Traces | 147s run time
=== RUN   TestTemplateBuildFuseConfiguration
=== PAUSE TestTemplateBuildFuseConfiguration
=== CONT  TestTemplateBuildFuseConfiguration
    build_template_test.go:133: test-ubuntu-fuse-config: [info] Building template qe5jbi17e01euimap0u3/fdac8cd9-6b3b-4ec1-bc8c-643b8db55bc1
    build_template_test.go:133: test-ubuntu-fuse-config: [info] [base] FROM ubuntu:22.04 [f9f564014e009a9561a82bf8c84f9314242971e833fb019936654ecba452f184]
    build_template_test.go:133: test-ubuntu-fuse-config: [info] Base Docker image size: 30 MB
    build_template_test.go:133: test-ubuntu-fuse-config: [info] Creating file system and pulling Docker image
    build_template_test.go:133: test-ubuntu-fuse-config: [info] Uncompressing layer sha256:d6834b4a794c03efa2c998853e64969fa8851b11b2ade63292268872a37759d0 30 MB
    build_template_test.go:133: test-ubuntu-fuse-config: [info] Uncompressing layer sha256:a6b6b9b254c8fac3dd9ffffcb6f8a26cd17d5affcb06fd26d2c9ee6f222b5acb 13 MB
    build_template_test.go:133: test-ubuntu-fuse-config: [info] Uncompressing layer sha256:8c4b1b28875140ed3abacaf16ad0d696f6bef912f52d2148f261a23e3349465b 168 B
    build_template_test.go:133: test-ubuntu-fuse-config: [info] Layers extracted
    build_template_test.go:133: test-ubuntu-fuse-config: [info] Root filesystem structure: bin, boot, dev, etc, home, lib, lib32, lib64, libx32, media, mnt, opt, proc, root, run, sbin, srv, sys, tmp, usr, var
    build_template_test.go:133: test-ubuntu-fuse-config: [info] Provisioning sandbox template
    build_template_test.go:133: test-ubuntu-fuse-config: [info] Provisioning was successful, cleaning up
    build_template_test.go:133: test-ubuntu-fuse-config: [info] Sandbox template provisioned
    build_template_test.go:133: test-ubuntu-fuse-config: [info] [base] DEFAULT USER user [49e586c2171254c6bc4a09e84eedac32dbcf113a158c24248129af2f49cbed74]
    build_template_test.go:133: test-ubuntu-fuse-config: [info] [builder 1/2] RUN grep -q 'z /dev/fuse 0666 root root -' /etc/tmpfiles.d/fuse.conf [064c2aa80e42051b5301f9fd4b4c1bab38adc2b717535b7ca42d5dc9fdc739d1]
    build_template_test.go:133: test-ubuntu-fuse-config: [info] [builder 2/2] RUN echo "Checking /dev/fuse permissions:"; ls -la /dev/fuse; stat -c 'mode=%a owner=%U group=%G' /dev/fuse; test $(stat -c %a /dev/fuse) = '666' [53b9173a7399b3bca212e8c5d1b705f01c09a1180ef92f57f3d3fc1db134b289]
    build_template_test.go:133: test-ubuntu-fuse-config: [info] [builder 2/2] [stdout]: Checking /dev/fuse permissions:
    build_template_test.go:133: test-ubuntu-fuse-config: [info] [builder 2/2] [stdout]: crw-rw-rw- 1 root root 10, 229 Jul 24 23:58 /dev/fuse
    build_template_test.go:133: test-ubuntu-fuse-config: [info] [builder 2/2] [stdout]: mode=666 owner=root group=root
    build_template_test.go:133: test-ubuntu-fuse-config: [info] [finalize] Finalizing template build [44ab07a644985d56c51c27da9742b1dd30905f459dd707130223567c87e03c43]
    build_template_test.go:133: test-ubuntu-fuse-config: [error] Build failed: An internal error occurred. Please try again or contact support with the build ID.
    build_template_test.go:1189: Build failed: {<nil> An internal error occurred. Please try again or contact support with the build ID. <nil>}
--- FAIL: TestTemplateBuildFuseConfiguration (146.77s)
View the full list of 1 ❄️ flaky test(s)
github.com/e2b-dev/infra/tests/integration/internal/tests/envd::TestListDir/depth_3_lists_all_directories_and_files

Flake rate in main: 30.77% (Passed 9 times, Failed 4 times)

Stack Traces | 0.01s run time
=== RUN   TestListDir/depth_3_lists_all_directories_and_files
=== PAUSE TestListDir/depth_3_lists_all_directories_and_files
=== CONT  TestListDir/depth_3_lists_all_directories_and_files
    filesystem_test.go:96: 
        	Error Trace:	.../tests/envd/filesystem_test.go:96
        	Error:      	Received unexpected error:
        	            	unavailable: 502 Bad Gateway
        	Test:       	TestListDir/depth_3_lists_all_directories_and_files
--- FAIL: TestListDir/depth_3_lists_all_directories_and_files (0.01s)

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

When a sandbox is running, the CORS headers on its host come from envd
inside the sandbox. When it is paused or killed there is no envd to
answer, so the proxy synthesizes the error — and that response carried no
`Access-Control-*` headers at all, which coupled CORS support to the
sandbox being alive. For anything probing liveness that is inverted: the
one answer the probe exists to deliver is the one a browser cannot
receive. `Sandbox.isRunning()` maps a 502 to `false`, but in a browser
`fetch` rejects before there is a status to read, so a bare
`TypeError: Failed to fetch` reaches the caller, indistinguishable from
being offline. Knock-on, `checkSandboxHealth` can never return `false`,
so 24 SDK call sites downgrade an actionable "sandbox was killed" timeout
to a generic network error.

Adding the headers to the 502 alone would not have fixed it. Every envd
request carries `E2b-Sandbox-Id` and `E2b-Sandbox-Port`, which are not
CORS-safelisted, so a browser always preflights first, and a preflight
response must have an ok status or the browser errors out no matter which
headers it carries. So the fix is two parts: answer preflights with 204
on the paths that would otherwise answer with an error status, and set
`Access-Control-Allow-Origin: *` (matching what envd sends) on the
synthesized responses themselves. `TemplatedError.HandleError` is the
choke point for all eight error templates, so it covers the whole family
at once; the raw `http.Error` paths go through the new `cors.Error`. Both
`handler()` and the pool's `ErrorHandler` — which owns `port_closed` and
the unreachable-sandbox 502 — answer preflights. The healthy path is
untouched: envd keeps answering its own preflights, and the proxy must
not add a second `Access-Control-Allow-Origin`, since a browser rejects
two values as readily as none.

Also fixes content negotiation, found while investigating. `template.go`
chose HTML over JSON by User-Agent sniffing, but a `fetch()` from page
scripts carries the browser's User-Agent and cannot override it (UA is a
forbidden header name), so an SDK call from a browser got the HTML error
page where it expects JSON — `Accept` was ignored entirely. Intent now
decides first: JSON when `Accept` prefers it, or when `Sec-Fetch-Mode` /
`X-Requested-With` mark the request as script-initiated. UA sniffing
stays as the fallback, where it only catches genuine top-level
navigations, which is what the HTML pages are for.

Verified with `go test -race ./packages/shared/pkg/proxy/...`, including
socket-level tests that reproduce the browser's preflight-then-request
exchange against a missing and an unreachable sandbox, plus `make fmt`
and `make lint`. Both new behaviors were checked to have teeth by
reverting the fix and watching the tests fail.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
mishushakov added a commit to e2b-dev/E2B that referenced this pull request Jul 24, 2026
Removes canObserveStoppedSandbox. The stopped-sandbox 502 arriving from the
edge without CORS headers is fixed in e2b-dev/infra#3389, so isRunning() can
report false in a browser again and checkSandboxHealth can return false, which
restores the actionable TimeoutError on the 24 call sites that use it.

Also un-gates host.test.ts 'ping server in non-running sandbox', which was
under the wrong flag: it asserts the edge's own 502 JSON envelope rather than a
response from a server inside the sandbox, so infra#3389 covers it too (the
same PR's content-negotiation fix is what keeps the body JSON rather than the
HTML error page). Its sibling 'ping server in running sandbox' does read the
user's python server, so that one keeps the gate.

canFetchSandboxServers stays for the four tests that read a response from a
server the test started in the sandbox — no CORS headers there, and one of them
sends a custom header that would preflight into python's 501. Its doc comment
no longer cites the proxy 502 as an example, since that now carries the header.

Until infra#3389 reaches production these 14 tests fail on the browser leg —
13 with 'TypeError: Failed to fetch' and one with the degraded SandboxError in
place of TimeoutError, matching the two documented consequences exactly. The
Node/Bun/Deno/workerd legs are unaffected (7 files / 26 tests green on Node).

Co-Authored-By: Claude <noreply@anthropic.com>
Comment on lines 197 to 203
HandleError(w, r)
if err != nil {
logger.L().Error(ctx, "failed to handle too many connections error", zap.Error(err), logger.WithSandboxID(d.SandboxId))
http.Error(w, "Failed to handle too many connections error", http.StatusInternalServerError)
cors.Error(w, "Failed to handle too many connections error", http.StatusInternalServerError)

return
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 The connection-limiting branch in handler() (the too many connections path) never calls cors.HandlePreflight, so a CORS preflight OPTIONS request that happens to land while the sandbox's connection limit is exceeded gets answered with a 429 instead of a 204.

Extended reasoning...

The bug. handler() only short-circuits CORS preflights for the getDestination failure path: if err != nil && cors.HandlePreflight(w, r) { ... } at the top of the function. That covers every branch that follows from a non-nil err (not-found, port-closed-via-template, invalid host/ID/port, etc.), because all of those checks are gated on err.

The connection-limiting block, however, only runs when err == nil — i.e. the sandbox did resolve successfully. When connLimitConfig.Limiter.TryAcquire fails because the sandbox is already at its concurrent-connection limit, the code falls straight into template.NewSandboxTooManyConnectionsError(...).HandleError(w, r) (lines 195-203) without ever asking whether the incoming request was a CORS preflight. TemplatedError.HandleError does call cors.SetHeaders(w) so the response carries Access-Control-Allow-Origin: *, but it also calls w.WriteHeader(e.vars.StatusCode()), and sandboxTooManyConnectionsData.StatusCode() is http.StatusTooManyRequests (429) — not an "ok" status.

Why this matters despite the headers being present. Per the Fetch/CORS spec, a browser only proceeds with the real request after a preflight if the preflight response has an ok HTTP status (2xx). Headers are irrelevant if the status fails that check — the browser aborts the whole request and the page's fetch() promise rejects before there is a status or body for JS to read. That is precisely the failure mode the rest of this PR is fixing (opaque TypeError: Failed to fetch instead of an actionable error), but this one branch was left uncovered.

Concrete walk-through:

  1. A sandbox is running and reachable, and connLimitConfig is configured with some maxLimit.
  2. The sandbox is already at maxLimit concurrent connections (e.g. legitimate load, or a burst of requests).
  3. A browser SDK call adds a non-safelisted header (e.g. E2b-Sandbox-Id), so the browser issues a CORS preflight: OPTIONS with Access-Control-Request-Method/Access-Control-Request-Headers.
  4. getDestination(r) succeeds (the sandbox is valid and routable) → err == nil → the top-level cors.HandlePreflight short-circuit at the start of handler() is skipped, because it's gated on err != nil.
  5. Execution reaches the connLimitConfig block; TryAcquire returns acquired == false because the limit is already exhausted.
  6. NewSandboxTooManyConnectionsError(...).HandleError(w, r) runs, writing Access-Control-Allow-Origin: * plus a 429 status.
  7. The browser evaluates the preflight response, sees a non-2xx status, and rejects the preflight per spec — it never sends the actual OPTIONS-guarded request. The page's fetch() throws a bare TypeError: Failed to fetch, with no way for the SDK to distinguish "too many connections" from being offline.

Fix. Mirror what the other two call sites (handler.go's top-level check and pool/client.go's ErrorHandler) already do: call cors.HandlePreflight(w, r) and return early before (or instead of) running the connection-limit check, so a preflight is always answered with 204 regardless of whether the underlying request would have been rate-limited. The real request that follows the preflight can then carry the informative 429/CORS-headers response as it does today.

Comment thread docs/ARCHITECTURE.md Outdated
Comment on lines +162 to +166
- **Sandbox proxy** (:5007, `pkg/proxy/`): reverse-proxies incoming traffic from client-proxy to
the sandbox's slot IP and requested port, enforcing per-sandbox traffic access tokens.
the sandbox's slot IP and requested port, enforcing per-sandbox traffic access tokens. A live
sandbox answers CORS itself (envd allows all origins); when the sandbox is missing or
unreachable the proxy synthesizes the response instead, so it carries its own CORS headers
and answers preflights (`pkg/proxy/cors/`) — otherwise a browser would hide the error from JS.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 The new parenthetical (pkg/proxy/cors/) in the Sandbox proxy bullet doesn't resolve to an existing path under packages/orchestrator/pkg/proxy/ (which only has proxy.go/metrics.go) — the cors package lives at packages/shared/pkg/proxy/cors/. Suggest fully qualifying it as packages/shared/pkg/proxy/cors/, matching the packages/shared/pkg/proxy/host.go reference used elsewhere in this same doc for the same package family.

Extended reasoning...

The bug: The new sentence added to the Sandbox proxy bullet reads:

Sandbox proxy (:5007, pkg/proxy/): ... and answers preflights (pkg/proxy/cors/) ...

packages/orchestrator/pkg/proxy/cors/ does not exist. Verified directly:

packages/orchestrator/pkg/proxy/   -> metrics.go, proxy.go   (no cors/ subdir)
packages/shared/pkg/proxy/cors/    -> cors.go, cors_test.go  (this PR's new package)

Why this happened: Elsewhere in the Orchestrator section, unqualified pkg/... paths are orchestrator-relative (pkg/sandbox/, pkg/server/, pkg/service/, pkg/volumes/ all resolve under packages/orchestrator/). The Sandbox proxy bullet's own header, (:5007, pkg/proxy/), appears to follow that same pattern, so a reader naturally expects pkg/proxy/cors/ to also be orchestrator-local — but it isn't.

The counter-argument (from a refutation) has real merit and I want to address it directly: packages/orchestrator/pkg/proxy/ itself is just a thin wrapper (proxy.go, metrics.go) that imports the real reverse-proxy engine from packages/shared/pkg/proxy (aliased reverseproxy) and packages/shared/pkg/proxy/pool. None of handler.go, pool/, template/, or the new cors/ exist under the orchestrator path — they're all under packages/shared/pkg/proxy/. So one could argue the bullet's own pkg/proxy/ was already loosely referring to "the proxy package" in the abstract, spanning both the orchestrator wrapper and the shared engine it wraps, and cors/ is consistent with that loose usage.

That reading is plausible, but it doesn't make the reference unambiguous or correct — it just means the bullet's pre-existing pkg/proxy/ header was already a little imprecise, and this PR's addition inherits and compounds that imprecision rather than resolving it. Critically, the same document already establishes the correct, unambiguous way to reference this exact package two sections below: packages/shared/pkg/proxy/host.go. That precedent exists precisely because unqualified pkg/proxy/ is not sufficient to locate the shared package unambiguously in this doc. Following that established precedent for the new cors/ reference would have avoided the ambiguity entirely.

Impact: Someone reading the doc and trying to jump to pkg/proxy/cors/ from the orchestrator package tree will not find it, costing them a grep/find round-trip. This is a pure documentation-navigation issue with no runtime or behavioral impact — the described CORS behavior itself is accurate.

Fix: Change (pkg/proxy/cors/) to (packages/shared/pkg/proxy/cors/) to match the fully-qualified packages/shared/pkg/proxy/host.go reference used elsewhere in the same document for the same package family.

Severity: This is a one-line, easily-fixed doc precision issue with no functional impact — nit, not blocking.

Comment on lines 197 to 203
HandleError(w, r)
if err != nil {
logger.L().Error(ctx, "failed to handle too many connections error", zap.Error(err), logger.WithSandboxID(d.SandboxId))
http.Error(w, "Failed to handle too many connections error", http.StatusInternalServerError)
cors.Error(w, "Failed to handle too many connections error", http.StatusInternalServerError)

return
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 The connection-limit path in handler() (reached only when getDestination succeeds) calls template.NewSandboxTooManyConnectionsError(...).HandleError directly, so it never runs through the new preflight short-circuit added at the top of the function. A CORS preflight OPTIONS against a live sandbox that's at its connection limit gets a bare 429 with no preflight answer, so per the Fetch spec the browser rejects it and never sends the real request - JS sees an opaque network error instead of the too-many-connections status, the exact class of bug this PR sets out to fix.

Extended reasoning...

The gap: handler() in packages/shared/pkg/proxy/handler.go gates its new preflight short-circuit on err != nil && cors.HandlePreflight(w, r) (around line 24). Every error branch that follows is guarded by an errors.As/errors.Is check on that same err, so they're all reachable only when err is non-nil. The connection-limit block (connLimitConfig != nil { ... TryAcquire ... }, lines ~182-203) sits after the final if err != nil { ... } catch-all and is therefore only reached when err == nil - i.e. getDestination succeeded and resolved a live sandbox destination d. Because the preflight short-circuit never fires on this path, a CORS preflight OPTIONS request that happens to land while the sandbox is at its connection limit falls straight into TryAcquire, and on failure into template.NewSandboxTooManyConnectionsError(d.SandboxId, r.Host, maxLimit).HandleError(w, r).

Why that response breaks the browser flow: sandboxTooManyConnectionsData.StatusCode() returns http.StatusTooManyRequests (429). TemplatedError.HandleError does add cors.SetHeaders(w) (this PR's own fix), so the 429 does carry Access-Control-Allow-Origin: * - but per the Fetch/CORS spec, a preflight response is only accepted by the browser if its status is in the ok range (200-299). A non-2xx preflight response is rejected regardless of what headers it carries, and the browser never sends the real (GET/POST/etc.) request that was gated behind it. The result: JS gets a bare network error (TypeError: Failed to fetch in browsers) with no visibility into the 429 or its body - exactly the failure mode ('browser can't see the synthesized error status') that this PR was written to eliminate for the other seven error templates.

Why nothing else catches this: The PR's own approach for every other synthesized-error path (sandbox not found, still transitioning, resume denied, team limit, missing/invalid traffic token, port closed, unreachable sandbox) is to run cors.HandlePreflight before falling into the corresponding HandleError/http.Error call. The connection-limit block was simply never wired into that short-circuit, because it lives on the success side of getDestination (err == nil) rather than the error side the new check was written for. HandlePreflight/isPreflight in cors.go has no awareness of connection limiting at all, so there is no fallback that would catch this.

Concrete walkthrough:

  1. A sandbox is live and already has maxLimit concurrent connections open (a legitimate, load-dependent state - not a bug on its own).
  2. Browser JS calls fetch() against the sandbox URL with the SDK's custom headers (E2b-Sandbox-Id, E2b-Sandbox-Port). Because these are non-safelisted headers, the browser first sends a preflight: OPTIONS with Access-Control-Request-Method: GET and Access-Control-Request-Headers listing those headers.
  3. handler() runs: getDestination(r) succeeds (err == nil, sandbox resolves), so the top-of-function err != nil && cors.HandlePreflight(...) check is false and falls through.
  4. Execution reaches the connLimitConfig block; TryAcquire fails because the sandbox is already at maxLimit. NewSandboxTooManyConnectionsError(...).HandleError(w, r) writes a 429 (JSON or HTML body, plus Access-Control-Allow-Origin: *, but with Access-Control-Allow-Methods/Access-Control-Allow-Headers never set and no 2xx status).
  5. The browser evaluates the preflight response: status 429 is not ok, so per the Fetch standard's CORS-preflight-fetch algorithm the whole request is failed. The actual GET is never sent.
  6. fetch()'s promise rejects with a generic network error - the caller (and the E2B SDK) cannot distinguish 'sandbox is overloaded' from 'network is down' or 'CORS misconfigured', losing exactly the signal (429 / too-many-connections) the backend tried to communicate.

Fix: extend the top-of-handler() short-circuit (or add an equivalent cors.HandlePreflight(w, r) check) so it also runs for the err == nil / connection-limit path - e.g. check cors.HandlePreflight(w, r) unconditionally near the top of handler(), before branching on err, rather than gating it on err != nil.

Severity: this is real but narrow - all three independent verifiers converged on nit. It only manifests when a live, already-connection-limited sandbox happens to receive a preflight at that exact moment; the ordinary GET request on this same path is already fixed correctly (the 429 does carry Access-Control-Allow-Origin: * via cors.SetHeaders), and this path had no CORS handling at all before this PR, so it's an incomplete corner of a genuine improvement rather than a regression introduced by it.

@linear-code

linear-code Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

SDK-294

Review catch. The connection limit is only reached when getDestination
succeeded, so it sits past the `err != nil` preflight check at the top of
`handler()` and never got one of its own. `HandleError` gave the response
`Access-Control-Allow-Origin`, but the status is 429 — not ok — so a
browser rejects it as a preflight result and never sends the request the
429 was meant for, leaving `sandbox_too_many_connections` exactly as
unreadable from JS as before this branch.

Answered inside the `!acquired` branch rather than by hoisting the check
to the top of `handler()`: preflights for healthy sandboxes belong to
envd, which owns a different allow-list, and the proxy should not start
answering them. Answering here reaches the sandbox no more than the 429
would, so it is not counted as a blocked connection — the real request
that follows still takes the 429 and the metric.

Also fully qualifies the new `packages/shared/pkg/proxy/cors/` path in
ARCHITECTURE.md: unqualified `pkg/...` in that section is
orchestrator-relative, where the package does not exist.

Verified the new test fails without the fix (429 instead of 204, and the
blocked-connection metric double-counting).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
@ValentaTomas
ValentaTomas force-pushed the main branch 2 times, most recently from 5aad415 to d71980e Compare July 25, 2026 22:53
@jakubno
jakubno removed their request for review July 29, 2026 07:07
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.

1 participant