Skip to content

Python SDK: unhandled RemoteProtocolError (TLS close_notify) during command streaming when sandbox ends #1726

Description

@AdaAibaby

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpythonPull requests that update Python codesdkImprovements or additions to SDKs

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions