Skip to content

feat: add sync and async sandbox SDK - #595

Open
KAJdev wants to merge 6 commits into
mainfrom
zeke/con-1525-create-sandbox-domain
Open

KAJdev wants to merge 6 commits into
mainfrom
zeke/con-1525-create-sandbox-domain

Conversation

@KAJdev

@KAJdev KAJdev commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

This brings sandbox support to the Python SDK, with a regular Sandbox API and an async AsyncioSandbox API. Both share the same lifecycle and execution logic.

use a context manager for a short-lived sandbox:

from runpod import Sandbox

with Sandbox(image_name="python:3.12-slim") as sandbox:
    result = sandbox.exec(["python", "-c", "print('hello from the sandbox')"], check=True)
    print(result.output)

The async version works the same way:

import asyncio
from runpod import AsyncioSandbox

async def main():
    async with AsyncioSandbox(image_name="python:3.12-slim") as sandbox:
        result = await sandbox.exec(["python", "-c", "print('hello')"], check=True)
        print(result.output)

asyncio.run(main())

To work with a sandbox that's already running, use get(sandbox_id) or list(...). Those handles only close their local connections when you leave the context. they don't terminate the sandbox. You can call terminate() explicitly when you're finished with it.

exec() returns command output, and check=True raises on command failure while keeping any partial output. logs() streams container or system logs and supports resuming with an event's id through last_event_id. Use a context manager around the log stream too if you might stop reading early.

While working on this, I discovered an edge case in log streaming on host that should be addressed as well: CON-1557 / runpod/host#2820.

Linear: CON-1523, CON-1524, CON-1525, CON-1526

Implements CON-1523, CON-1524, CON-1525, and CON-1526.

Based on REST migration PR #584. Keep this draft local until that PR is merged.

Includes managed ownership, bounded startup handling, closeable SSE streams,
and cancellation-safe synchronous and asynchronous resource cleanup.
Comment thread runpod/sandbox/asyncio.py Fixed
Comment thread tests/test_sandbox.py Fixed
Comment thread tests/test_sandbox.py Fixed
Comment thread tests/test_sandbox.py Fixed
Comment thread tests/test_sandbox.py Fixed
Comment thread runpod/sandbox/asyncio.py Fixed
Comment thread runpod/sandbox/asyncio.py Fixed
Comment thread runpod/sandbox/asyncio.py Fixed
Comment thread runpod/sandbox/asyncio.py Fixed
Comment thread runpod/sandbox/asyncio.py Fixed
Comment thread tests/test_sandbox.py Fixed
Comment thread tests/test_sandbox.py Fixed
Comment thread runpod/sandbox/asyncio.py Fixed
Comment thread runpod/sandbox/asyncio.py Fixed
Comment thread runpod/sandbox/asyncio.py Fixed
Comment thread runpod/sandbox/asyncio.py Fixed
Comment thread runpod/sandbox/asyncio.py Fixed
Comment thread runpod/sandbox/asyncio.py Fixed
Comment thread runpod/sandbox/asyncio.py Fixed
Comment thread runpod/sandbox/asyncio.py Fixed
Comment thread tests/test_sandbox.py Fixed
Comment thread tests/test_sandbox.py Fixed
Comment thread runpod/sandbox/asyncio.py
Comment thread runpod/sandbox/asyncio.py
Comment thread runpod/sandbox/asyncio.py
Comment thread runpod/sandbox/sync.py
Comment thread runpod/sandbox/sync.py
Comment thread tests/test_sandbox.py Fixed
Comment thread tests/test_sandbox.py Fixed
Comment thread runpod/sandbox/asyncio.py
Comment thread runpod/sandbox/asyncio.py
Comment thread runpod/sandbox/asyncio.py
Comment thread runpod/sandbox/sync.py
Comment thread runpod/sandbox/sync.py

@runpod-Henrik runpod-Henrik 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.

QA review of the SDK surface, diff only.

Good call on ExecResult(output=data["output"], error=data.get("error")). A successful exec omits the error key entirely rather than sending null, despite the OpenAPI text describing it as "null when the command succeeded" — a strict is None comparison there reported every real success as a failure in another consumer, with green unit tests the whole time. Using .get() sidesteps it. Worth a comment on that line so nobody "tidies" it into an is None check later.

Four things.

1. AsyncioSandbox vs the specified AsyncSandbox

The design doc names the async entry point AsyncSandbox, and the stated reason is specific rather than aesthetic: it matches E2B's exact class name, so developers arriving from E2B or Daytona don't have to relearn a term. AsyncioSandbox gives up precisely that benefit.

The PR is also internally split on it — __all__ exports AsyncioSandbox alongside AsyncSandboxLogStream, so both prefixes ship in the same namespace. Whichever way this goes, the two should agree.

This is cheap to change now and a breaking change later, which is why it's worth settling before merge rather than after.

2. check: bool = False inverts the documented default

The design doc lists the raise-on-failure default as an open question and records the lean as raising. This ships the opposite: a command that exits non-zero returns an ExecResult and execution continues unless the caller opted in.

Two honest sides to this. check is the better name — it matches subprocess.run, and Python developers will read it correctly with no docs. But the default matters more than the name for this product: the entire use case is executing code a model just wrote, where silently proceeding past a failed command is how an agent ends up building on a step that didn't happen. E2B and the other SDKs in this category raise by default.

Not asking for a specific answer, just that the default is chosen deliberately and recorded, rather than inherited from subprocess.

3. This is the public repo

Shipping sandbox support into public runpod-python while the feature is email-gated and dark in prod means users can discover, install and build against an API that isn't generally available yet. That's happened before on another product and produced support load plus pressure not to change the surface — which is exactly the surface still being decided in points 1 and 2.

Options are a private repo until GA, or release tagging that keeps it off the default install. Either is fine; drifting into GA by publication is the one to avoid.

4. data["output"] is a hard index on a 200

output is required in the schema, so this is spec-correct. The failure mode it misses is a malformed 200 that isn't the API at all — a proxy error page, an HTML interstitial — which raises KeyError rather than anything a caller can act on. Another consumer of this endpoint hit exactly that shape on create. A .get("output", "") with an explicit error, or letting the existing response validation catch it, turns a confusing traceback into a real message.

Not gaps, for the record

No upload_file / download_file: there are no file-transfer endpoints in the REST contract yet, so that's blocked upstream rather than missing here. Same for anything depending on streaming exec.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants