e2b-dev / e2b-dev/E2B

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

Open
#1,726 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug python sdk
Dominant language
Python
Stars
13.9k
Forks
1k
Avg merge
1d 20h
Merged PRs (30d)
70

Description

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

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with call_server_stream and acall_server_stream in packages/python-sdk/e2b_connect/client.py, then trace handle_rpc_exception in e2b/envd/rpc.py and wait() in both command_handle.py files. Verify TLS EOF during streaming is converted into a readable SandboxException and that missing exit events no longer raise a bare Exception. Confirm normal stream completion and sandbox termination behavior through the relevant Python SDK command-streaming checks.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
api
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
52/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.