ADR-0017 — SandboxTimeout does not inherit the builtin; timeouts split by phase¶
Status: Accepted Date: 2026-09-19 Resolves: Q4 Related: ADR-0010, ADR-0015
Context¶
The input taxonomy named the timeout class TimeoutError (SBX_E1302), shadowing the
builtin. Since 3.11 — our floor (ADR-0015) — the builtin
TimeoutError is what asyncio and anyio raise, so except sandboxio.TimeoutError
becomes ambiguous about which library's timeout it catches.
The decisive constraint is that builtin TimeoutError is a subclass of OSError
(MRO: TimeoutError → OSError → Exception). Inheriting it therefore imports OSError into
our tree: except OSError would swallow sandbox timeouts, every instance carries dead
errno / strerror / filename attributes, and OSError.__str__ and __reduce__ need
overriding to keep our rendering contract.
Prior art is genuinely split, so it does not settle the question:
| Library | Class | Shadows the name | Inherits the builtin |
|---|---|---|---|
| httpx | TimeoutException(TransportError) |
no | no |
| aiohttp | ServerTimeoutError(ServerConnectionError, asyncio.TimeoutError) |
no | yes |
| redis-py | TimeoutError(RedisError) |
yes | no |
| urllib3 | TimeoutError(HTTPError) |
yes | no |
httpx and urllib3 both additionally split timeouts into granular subtypes.
Separately, the catalog had one timeout code for situations with different remedies —
creation timing out (the backend is slow) versus execution timing out (the agent's code is
hung) — and no error at all for a sandbox whose own lifetime ends mid-use, which would
have surfaced as a confusing ExecutionError or ConnectError.
Decision¶
No sandboxio exception inherits from a builtin exception, and no name in the sandboxio
namespace shadows a builtin. Enforced by ruff's flake8-builtins rules (A001/A004), not by
review.
SandboxError
├── CreationError SBX_E1201
├── ConnectError SBX_E1202
├── CreateTimeout(CreationError, SandboxTimeout) SBX_E1203
├── SandboxGone SBX_E1204
├── ExecutionError SBX_E1301
├── SandboxTimeout — base, never raised directly
│ └── ExecutionTimeout SBX_E1302
└── …
SandboxTimeoutis a catch-all base with no code of its own.except SandboxTimeoutgets every timeout; the concrete classes carry the codes.SBX_E1302keeps its meaning — execution timed out — so nothing already documented changes meaning.CreateTimeouttakes a new lifecycle code rather than reusing it.CreateTimeoutinherits bothCreationErrorandSandboxTimeout, so either catch works. Multiple inheritance inside our own tree is unambiguous and cheap.SandboxGone(SBX_E1204) is not a timeout. It means the sandbox no longer exists — its lifetime expired, or the backend reclaimed it — while the caller was still using it. Its hint points at the sandboxtimeout=that governs lifetime, which is the actual fix.- No
sandboxio.TimeoutErroralias. One name per concept. - Internally,
anyio.fail_afterraises the builtinTimeoutError; adapters and core catch it and re-raise the matching sandboxio class with__cause__preserved. A bare builtinTimeoutErrorescaping any public entry point is a bug, checked by the contract suite.
Consequences¶
except TimeoutErrordoes not catch sandboxio timeouts. This is the cost, and it is paid deliberately.- In exchange we get a distinction that inheritance would destroy: with an outer
asyncio.timeout()around a sandbox call, builtinTimeoutErrormeans the caller's deadline fired andSandboxTimeoutmeans the sandbox's own limit fired. Under the inheriting design these are indistinguishable withoutisinstance. For a library whose thesis is legible errors, telling two deadlines apart is worth more thanOSErrorinterop. - The generic-catch use cases are already served:
except SandboxErrorfor "anything sandboxio can raise", and.codefor a stable programmatic token (ADR-0010). - Users porting from a provider SDK that raises builtin timeouts must change their
exceptclause. The migration guide must say so explicitly, because the failure mode is an uncaught exception rather than a type error. - Three new codes means three new docs pages, generated from the catalog like the rest.
- The no-shadowing rule is general, so a future
ConnectionError-style name is already decided against.