feat(errors): typed SandboxError subclasses at the runtime seam - #2580
Open
faresobeid wants to merge 3 commits into
Open
feat(errors): typed SandboxError subclasses at the runtime seam#2580faresobeid wants to merge 3 commits into
faresobeid wants to merge 3 commits into
Conversation
Runtimes hand every failure to callers as one flat SandboxError with a message, so a consumer that must tell "retry later" from "the box is gone" matches prose. Add six subclasses naming the fault (not_found, timeout, unavailable, denied, disk_full, provisioning) with a class-level `code` recorded as `trace.Error.code`; `sandbox_error()` maps a runtime failure from typed evidence only (exception type, errno, HTTP status, Connect RPC code, read down the exception chain) and mirrors `model_error`. The prime runtime reads the SDK's own types first at its mapping sites; the retry filter matches the error's class family so `include: ["SandboxError"]` still covers every subclass and `exclude: ["SandboxDeniedError"]` works.
faresobeid
marked this pull request as ready for review
September 10, 2026 20:22
…andboxNotFoundError too
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 37b3065. Configure here.
Contributor
ApprovabilityVerdict: Not approved Macroscope's review found this PR not approvable — This PR introduces a public typed-error capability while changing Prime, subprocess, harness, trace, and opt-in retry behavior. The cross-cutting runtime effects and retry/reprovision implications warrant human review despite the strong test coverage. You can add or adjust custom eligibility rules. Learn more. |
…ee past `Runtime.alive()` returned False on any exception, so the harness's failure-path probe turned a transient SandboxUnavailableError (or a SandboxTimeoutError) from its `true` exec into SandboxNotFoundError, and a retry policy that retries unavailable but reprovisions on not-found would replace a box that was only briefly unreachable. `alive()` now answers False only when the box is gone (non-zero exit, SandboxNotFoundError) or nothing typed says more (a bare SandboxError, an unmapped exception); any other typed SandboxError subclass is the runtime's real answer and propagates.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Problem
Every runtime failure reaches a caller as one flat
SandboxErrorwhose only content is a message, so a consumer that must tell "retry later" from "the box is gone" from "the provider refused" has to match prose. Our data-flywheel environment (a long-lived seat over prime sandboxes) does exactly that today: substring checks onstr(error)for"being placed on a node","Failed to route","routing catalog","(unavailable)", and a walk down__cause__looking forFileNotFoundError/httpx 404 — ~40 lines of classification that break each time the platform's wording moves ("routing catalog"is new this month). Two production runs (r2/r3) show what those messages carry: ~95×prime exec failed: Connect RPC failed (unavailable): The sandbox routing catalog is temporarily unavailable; retry shortly, 12×(unavailable): The sandbox is being placed on a node; retry shortly, 2×(unavailable): Failed to route request to sandbox, 119×prime sandbox provisioning failed: Sandbox did not reach RUNNING within 355s— all transient or provisioning faults, all arriving as the same type. verifiers' own whole-rollout retry filter (RetryConfig.include/exclude) matches by exception type name, so nobody can say "retry unavailable, never denied" either. The runtime knows the typed cause (an SDK exception class, an HTTP status, a Connect RPC code) the moment it wraps it; this PR keeps that knowledge in the class.Reworks #2574 (closed in favour of this): classes instead of an instance
codestring, soexcept SandboxUnavailableErrorandRetryConfigwork and theError.codeis derived from the class rather than a second vocabulary; the nineexcept … raise … from esites inprime.pykeep their shape; no_faultscontext manager, no cancellation handling, no monkeypatch of the SDK's live-process pump (the cancellation contract goes upstream to prime-sandboxes separately).API
sandbox_erroris the runtime counterpart ofmodel_error: it reads typed evidence offeand the failures it chains (__cause__, else the implicit__context__— the prime SDK raisesAPIError("HTTP 503: …")inside the httpx handler withoutfrom), never the message text —FileNotFoundError,TimeoutError/httpx.TimeoutException,OSError(ENOSPC),httpx.HTTPStatusError(404 / 401-403 / 408-504 / 429-5xx), otherhttpx.TransportError, andconnectrpc.ConnectErrorby code (NOT_FOUND / DEADLINE_EXCEEDED / UNAVAILABLE-RESOURCE_EXHAUSTED-ABORTED / PERMISSION_DENIED-UNAUTHENTICATED). Anything else isdefault.connectrpcis imported lazily inside the RPC check (it ships with prime-sandboxes; the module stays importable without it).runtimes/prime.pyadds an_error(what, e, default=…)that reads the SDK's own types first (SandboxFileNotFoundError,UnauthorizedError/PaymentRequiredError, the five SDK timeout classes,SandboxImagePullError,SandboxOOMError/ aSandboxNotRunningErrorwhosestatusis TERMINATED/ERROR/TIMEOUT — the SDK's own "gone" set) and falls back tosandbox_error. The nine mapping sites becomeraise _error("prime exec failed", e) from e;start()passesdefault=SandboxProvisioningError; the egress-policy 60 s expiry raisesSandboxTimeoutError.runtimes/subprocess.py's read maps throughsandbox_error(a missing file →SandboxNotFoundError).harness._check_resultraisesSandboxNotFoundErrorfor "runtime died" (the failedalive()probe is structural evidence, not text) — flagging this one; happy to keep it bare if you'd rather.trace.Errorgainscode: str | None = None, filled from the class inTrace.record_errorandenv._as_error(only forRolloutErrors — arbitrary exceptions'.codeattributes, e.g.openai.APIError.code, are not read).utils.retries._retryablematches the recorded type's class family (its name and itsRolloutErrorbases', looked up inverifiers.v1.errors), soinclude: ["SandboxError"]still matches every subclass andexclude: ["SandboxDeniedError"]is expressible; a type not defined there matches only itself, as before.Consumer side, what replaces the prose matching:
What is preserved
except SandboxErrorcatches everything it did; every subclass is aSandboxError. Messages keep their"<what>: <cause>"shape (the one exception: the port-exposure message ends…instead: <cause>rather than…instead. (<cause>)).SandboxErrorand behave exactly as today; the text-only sites (_read's non-zero exit, the non-VMopen_processrefusal, docker/modal/base) are untouched.RetryConfig.include/excludekeep their meaning for every existing config;Error.codeis optional, so old records load withNone. No new dependency, nopyproject.tomlchange, no config surface.trace.last_error.typefor a mapped failure is now the subclass name ("SandboxUnavailableError") rather than"SandboxError"— the same conventionTunnelError(InterceptionError)already follows. verifiers' own filter is family-aware; external code comparingtype == "SandboxError"exactly needs to widen.Tests
tests/v1/test_errors.py(deterministic, no network, no model):sandbox_errorover the evidence table incl. the two SDK shapes (APIError … from ConnectError(UNAVAILABLE)— the flywheel case — and a bareAPIError("HTTP 503: …")with the typed status only on__context__), andRuntimeError("No such file or directory")staying bare; prime's_errorover the SDK types, a bareAPIErrorstaying bare, and the provisioning default instart()yielding to typed evidence;record_error→Error.type/codeand theto_record→model_validateround trip, aProviderErrorand an old record carryingcode is None;_retryablefamily matching (include=["SandboxError"]matches aSandboxUnavailableError;excludewins; an unknown type matches only itself);SubprocessRuntime.read("missing")→SandboxNotFoundError.uv run pytest tests/v1 -m "not e2e",ruff,pre-commit run --all-files,ty check verifiersall pass.Note
Add typed
SandboxErrorsubclasses with stable codes at runtime seamSandboxNotFoundError,SandboxTimeoutError,SandboxUnavailableError,SandboxDeniedError,SandboxDiskFullError, andSandboxProvisioningErrorin errors.py, each carrying a stablecodeonRolloutError.sandbox_errorand Prime-specific_errorfactories scan exception chains (HTTP status, Connect RPC code, errno, timeout, SDK type) to pick the most specific subclass, falling back to a caller-provided default.RolloutError.codeis persisted on trace.pyErrorrecords via_as_errorin env.py; existing traces without a code remain valid.RolloutErrornames to their inheritance family, so a policy listing a base class retries its subclasses.PrimeRuntimeandSubprocessRuntimereplace genericSandboxErrorwrapping with the new factories across start, run, read, write, expose, and background-launch paths.Runtime.aliveandHarness._check_resultnow treat onlySandboxNotFoundErroras a dead runtime, propagating other typed probe failures unchanged.Runtime.alivein base.py andHarness._check_resultin harness.py change liveness semantics — any out-of-tree code that caughtSandboxErrorbroadly will now receive specific subclasses that may not match existingexceptclauses.Macroscope summarized c93fdee.
Note
Medium Risk
Changes how sandbox failures are typed and recorded on traces (
last_error.typebecomes subclass names), which can affect external code that matched"SandboxError"exactly or relied on message substrings; core rollout/retry paths are intentionally updated but downstream consumers need to widen handling.Overview
Replaces flat
SandboxErrormessages with six typed subclasses (SandboxNotFoundError,SandboxTimeoutError,SandboxUnavailableError,SandboxDeniedError,SandboxDiskFullError,SandboxProvisioningError), each with a stable class-levelcoderecorded on traces.Adds
sandbox_error()to classify failures from exception chains (errno, httpx status/transport, Connect RPC codes,__cause__/__context__) without parsing message text. Prime runtime uses_error()to map prime-sandboxes SDK types first, thensandbox_error; provisioning defaults toSandboxProvisioningError. Subprocess read paths mapOSErrorthrough the same helper (missing file →SandboxNotFoundError).trace.Errorgains optionalcode; episode capture copies it fromRolloutError. Whole-rolloutRetryConfigmatching treats a listed base type (e.g.SandboxError) as matching subclasses, withexcludestill winning.Harness dead-runtime handling raises
SandboxNotFoundError;Runtime.alive()re-raises typed faults like unavailable/timeout instead of treating them as “gone.” Addstests/v1/test_errors.pyfor classification, trace round-trip, retries, subprocess, and harness probe behavior.Reviewed by Cursor Bugbot for commit c93fdee. Bugbot is set up for automated code reviews on this repo. Configure here.