Summary
When a sandbox is killed, paused, or times out while a command is streaming output, the Python SDK surfaces a raw httpcore.RemoteProtocolError instead of a meaningful SandboxException. The user gets an opaque transport-layer exception with no indication that the sandbox lifecycle is the cause.
Observed frequency: ~3/52 965 command requests (0.006%) — low but real, and the error is confusing.
Root cause
The E2B orchestrator intentionally closes backend connections with SetLinger(0) (TCP RST) when a sandbox ends, to prevent connection reuse across sandbox lifecycle boundaries. This is by design in the infra. The RST propagates through the GCP load balancer, which then closes the downstream TLS connection without sending a TLS close_notify alert.
On the client side, rustls (used by the aenv component) raises:
peer closed connection without sending TLS close_notify
httpcore wraps this as httpcore.RemoteProtocolError and it bubbles up through the SDK unhandled.
Error propagation path in the SDK
Commands._start()
→ ProcessClient.start()
→ connect.Client.call_server_stream() # e2b_connect/client.py
→ for chunk in http_resp.iter_stream(): # ← RemoteProtocolError raised here
yield parsed
→ CommandHandle._handle_events()
→ for event in self._events: # iterates the generator above
...
except Exception as e:
raise handle_rpc_exception(e) # e2b/envd/rpc.py
Problem 1 — handle_rpc_exception passes RemoteProtocolError through unchanged:
def handle_rpc_exception(e, error_map=None):
if isinstance(e, ConnectException):
... # only handles ConnectRPC protocol errors
else:
return e # ← RemoteProtocolError returned as-is, no mapping
Problem 2 — @_retry(RemoteProtocolError, 3) on call_server_stream is a no-op for streaming:
The decorator wraps a generator function. return func(*args, **kwargs) returns the generator object immediately without executing any body. The exception only occurs during iteration (inside _handle_events), outside the decorator's try/except. So the retry never fires during streaming.
Problem 3 — wait() raises a generic Exception when the stream ends without an end event:
if self._result is None:
raise Exception("Command ended without an end event") # not SandboxException
Suggested fix
e2b_connect/client.py — catch TLS EOF in the body-reading loop and treat it as clean stream end. The ConnectRPC envelope framing (FLAG_END) is the authoritative end-of-stream signal; TLS close_notify is not required:
def _is_tls_eof(exc):
msg = str(exc).lower()
if isinstance(exc, RemoteProtocolError):
return "close_notify" in msg or "unexpected eof" in msg
if isinstance(exc, ssl.SSLEOFError):
return True
cause = exc.__cause__ or exc.__context__
return cause is not None and cause is not exc and _is_tls_eof(cause)
# In call_server_stream / acall_server_stream:
try:
for chunk in http_resp.iter_stream():
for parsed in parser.parse(chunk):
yield parsed
except Exception as exc:
if _is_tls_eof(exc):
return # sandbox ended; caller surfaces a clear error if no end event was received
raise
e2b/envd/rpc.py — map transport EOF to a readable SandboxException:
def handle_rpc_exception(e, error_map=None):
if isinstance(e, ConnectException):
... # existing logic
if _is_transport_eof(e):
return SandboxException(
"Sandbox connection closed unexpectedly. "
"The sandbox may have been killed, paused, or timed out. "
f"Original error: {e}"
)
return e
command_handle.py (sync + async) — use SandboxException instead of bare Exception:
if self._result is None:
raise SandboxException(
"Command stream ended without an exit event. "
"The sandbox may have been killed, paused, or timed out."
)
Files to change
| File |
Change |
packages/python-sdk/e2b_connect/client.py |
Catch TLS EOF in call_server_stream + acall_server_stream body loops |
packages/python-sdk/e2b/envd/rpc.py |
Map transport EOF → SandboxException in handle_rpc_exception |
packages/python-sdk/e2b/sandbox_sync/commands/command_handle.py |
SandboxException instead of bare Exception in wait() |
packages/python-sdk/e2b/sandbox_async/commands/command_handle.py |
Same |
Summary
When a sandbox is killed, paused, or times out while a command is streaming output, the Python SDK surfaces a raw
httpcore.RemoteProtocolErrorinstead of a meaningfulSandboxException. The user gets an opaque transport-layer exception with no indication that the sandbox lifecycle is the cause.Observed frequency: ~3/52 965 command requests (0.006%) — low but real, and the error is confusing.
Root cause
The E2B orchestrator intentionally closes backend connections with
SetLinger(0)(TCP RST) when a sandbox ends, to prevent connection reuse across sandbox lifecycle boundaries. This is by design in the infra. The RST propagates through the GCP load balancer, which then closes the downstream TLS connection without sending a TLSclose_notifyalert.On the client side,
rustls(used by the aenv component) raises:httpcorewraps this ashttpcore.RemoteProtocolErrorand it bubbles up through the SDK unhandled.Error propagation path in the SDK
Problem 1 —
handle_rpc_exceptionpassesRemoteProtocolErrorthrough unchanged:Problem 2 —
@_retry(RemoteProtocolError, 3)oncall_server_streamis a no-op for streaming:The decorator wraps a generator function.
return func(*args, **kwargs)returns the generator object immediately without executing any body. The exception only occurs during iteration (inside_handle_events), outside the decorator'stry/except. So the retry never fires during streaming.Problem 3 —
wait()raises a genericExceptionwhen the stream ends without an end event:Suggested fix
e2b_connect/client.py— catch TLS EOF in the body-reading loop and treat it as clean stream end. The ConnectRPC envelope framing (FLAG_END) is the authoritative end-of-stream signal; TLSclose_notifyis not required:e2b/envd/rpc.py— map transport EOF to a readableSandboxException:command_handle.py(sync + async) — useSandboxExceptioninstead of bareException:Files to change
packages/python-sdk/e2b_connect/client.pycall_server_stream+acall_server_streambody loopspackages/python-sdk/e2b/envd/rpc.pySandboxExceptioninhandle_rpc_exceptionpackages/python-sdk/e2b/sandbox_sync/commands/command_handle.pySandboxExceptioninstead of bareExceptioninwait()packages/python-sdk/e2b/sandbox_async/commands/command_handle.py