Skip to content

Failures

Failures here have two audiences, and the two never see the same thing. Your application sees a fault: something under the execution broke, the call rejects, and the error carries a code to branch on. The model whose code just ran sees a declared failure: text written for it to act on. That text arrives in the result envelope, or as a rejection the program catches without ever leaving the sandbox. Between the two, the failure grammar draws the line, and this page is the inventory on both sides of it.

Every text below is quoted as the code composes it. These strings are contract, not prose. A spec file pins every failure class with an exact-string assertion, so rewording one of those messages fails pnpm verify long before a release. Most of the classes live in packages/protocol/src/errors.ts, and every text that comes from somewhere else names the module that builds it. This page transcribes those pins and nothing compares the two, so where a row here and the suite ever disagree, the suite is the contract.

Which of the two you are holding is decided by where it arrived, not by what it says:

flowchart TD
  start["Something went wrong with an execution"] --> where{"Did the execute call reject?"}
  where -->|"yes"| coded{"Does the error carry a code?"}
  where -->|"no, it resolved"| envelope{"Does the result carry error?"}
  coded -->|"yes"| fault["A fault. Branch on the code, and retry only when retryable is true."]
  coded -->|"no"| handle["A handle failure. It has a pinned text and no code, so read the text."]
  envelope -->|"yes"| declared["A declared failure. The text is written for the model to act on."]
  envelope -->|"no"| fine["The run settled. result is what the arrow answered."]
  fault --> codes["Fault codes"]
  handle --> surface["Handle failures without a code"]
  declared --> texts["Declared failures a model reads"]

Programs can also catch a declared failure inside the sandbox before it ever reaches the envelope. Catching one is the point of writing these texts for the model. fetch with outbound closed rejects with a text saying to ask through a tool call, and code that catches it does exactly that.

A fault throws. Every fault the executor throws carries a code from the taxonomy in packages/protocol/src/errors.ts. But a code exists there only while a consumer can meet it on a thrown error. Failures that reach the model as text carry no code at all.

Codes cross a process boundary and an RPC boundary as an own property of the error. A class this repository declares crosses neither. capnweb carries the pool’s traffic to its children over the IPC channel and the remote executor’s traffic over a WebSocket. It passes an Error by value: the own properties survive, but the class is rebuilt from the name. So a subclass this repository declares arrives as a plain Error. Below, the handle-not-found text is the visible case. It is composed inside the child. It reaches the parent as a plain Error whose name reads Error, carrying only its message. A host therefore branches on code, and rehydrateExecutorError builds the typed error back inside the catch:

import { ExecutorError, rehydrateExecutorError } from '@supolka/codemode-executor'
try {
return await executor.execute({ code })
} catch (failure) {
const error = rehydrateExecutorError(failure)
if (error instanceof ExecutorError && error.retryable) {
await wait(backoff)
return await executor.execute({ code })
}
throw error
}

That instanceof holds because rehydrateExecutorError builds the instance in the calling process. It is safe on anything you can catch: an error whose code it does not recognize comes back untouched, so a provider rejection or a throw from user code keeps its original shape, and a rejection that was never an Error at all comes back as an INTERNAL fault instead of crashing the handler. Waiting makes the retry worth attempting: a queue that was full the moment it rejected is still full an instant later.

Every row below is a value of ExecutorErrorCode. That retryable column is the retryable property the error carries, and the sample above reads it. No code in the taxonomy asks a host to parse a message.

Code What broke, and where a host meets it Retryable What the host does
QUEUE_OVERFLOW execute found the pool’s waiting line at its bound, in packages/executor/src/pool/queue.ts. Always Retries after a running execution settles, and scales on the queue depth.
WORKER_CRASHED The child process under the execution died, never spawned, failed to start or stopped answering heartbeats, in packages/executor/src/pool. Always Retries once. The pool has already replaced the worker.
PROTOCOL_MISMATCH The versions disagreed at the handshake, the wire carried an outbound mode the server does not know, or a stub mode arrived without its outbound function. No Deploys matching versions. Reconnecting never heals it, and the remote executor stops retrying on it.
CONNECTION_REJECTED connect failed on the socket, on the handshake that follows it, or because headers were passed without an injected WebSocket implementation. No Checks the address, the handshake headers and whatever admits the connection.
CONNECTION_BROKEN The transport under a call died, whether the IPC channel, the WebSocket session or a disposed executor. When the transport can be reopened Reads retryable. A broken session reopens on the next call; a disposed executor does not.
RESPONSE_TOO_LARGE The server measured the result envelope, or a handle invocation’s answer, above its configured response limit. No Returns less, or raises responseLimit on the server.
HANDLES_CLOSED invokeHandle or closeHandles named an execution whose handles are gone. No Executes again with retainHandles to get a fresh executionId.
INTERNAL An invariant of the executor itself broke, the pool is shutting down, or rehydrateExecutorError met a rejection that was not an Error at all. No Reports it. Nothing a caller changes about the request makes it go away.

Fault messages are written for whoever reads the incident afterwards, never for the branch. Where the caller can actually do something about it, the class composes its message to the same grammar the declared failures use below. A model can read that text unchanged.

Class Code Text
QueueOverflowError QUEUE_OVERFLOW The queue is at its bound of <maxQueue>. Retry when a running execution settles.
ResponseTooLargeError RESPONSE_TOO_LARGE The execution response of <size> bytes exceeds the response limit of <responseLimit> bytes. Return less data, or raise the response limit on the server.
HandlesClosedError HANDLES_CLOSED Execution "<executionId>" holds no handles: they were closed, expired at the idle bound, or never granted. Execute again with retainHandles to get a fresh executionId.

Every byte and character count in a declared text is grouped for the en-US locale, not for the host’s. So a size reads the same wherever the executor runs. A test can pin the whole sentence.

invokeHandle rejects with an error that carries no code when the failure belongs to the handler, not to the executor. Each shape below arrives as a plain Error whose message is the text.

Text What causes it
Execution "<executionId>" has no handle named "<handleName>". The granted handles are: <granted>. The name is outside what the grant carried. GRANTED_HANDLES in packages/executor/src/sandbox/handles.ts is that list and it holds fetch. The handles keep serving. A caller that constructs the class with an empty list reads That execution granted no handles. instead.
Whatever the handler threw The retained fetch handler rejected. The handles keep serving.
The fetch handle answered with a value that is not a Response. The handler returned something else. The check runs inside the isolate, in the invoker that packages/executor/src/sandbox/handles.ts installs.
The fetch handle answered outside the response wire shape. The answer passed the check inside the isolate and failed the one that same module runs on the host. A Response subclass reaches that state by overriding what the serialization reads.
The handle answer is <size> characters, past the <limit> characters one execution may send across the boundary at a time. Send a summary and keep the rest inside the run. The response the handler built passed PAYLOAD_LIMIT. PayloadTooLargeError composes it and the handles keep serving.

Invocations that outlive their timeout or exhaust the memory limit are a different case. Each rejects with the timeout or memory text of the declared failures below and closes the handles. Disposing the isolate stops the invocation.

A declared failure is text, and the text is the whole interface. Each one is written as two sentences: the first names what failed and why, the second says what remains possible. That second sentence is why the tables below carry no advice column. The instruction is already in the message, in the words a model should read, so a host renders it and adds no words of its own. Under Tool calls the pinned dispatch answers are the exception, because the dialect fixes their wording and this grammar does not.

These are the failures a program meets by asking for more than the execution was granted. Each text names the bound the execution actually ran under, so the model reads its own number and not a configured maximum it never had.

Class Text What causes it
ExecutionTimeoutError Execution timed out at its <timeout> ms bound. Do less work per run and return the part that is already finished. The run outlived its bound on the run clock. That clock pauses while the caller serves a wait.
MemoryLimitError Execution ran out of memory at its <memoryLimit> MB limit. Work through the data in smaller pieces and keep only what you return. The engine ended the isolate at its heap limit, or a retained isolate was found dead on the next invocation.
OutboundDisabledError Outbound fetch is disabled for this execution. Anything this run needs from outside has to arrive through a tool call. fetch was called with outbound closed.
PayloadTooLargeError The <part> is <size> characters, past the <limit> characters one execution may send across the boundary at a time. Send a summary and keep the rest inside the run. One value the run sends outward passed PAYLOAD_LIMIT. The protocol package’s constants give that bound its number. The part reads run's answer, tool call, outbound request or handle answer.
Console capture Console output reached this execution's limit of <limit> characters and the rest was dropped. Return what the caller needs in the result. The captured lines of the whole run together reached CONSOLE_CAPTURE_LIMIT. CONSOLE_CAPTURE_MESSAGE in packages/protocol/src/protocol.ts composes the text from that bound. The line that met the bound is dropped whole and this one is appended as a warn.

The console text is the one declared failure that arrives as a log line, not as a rejection or an envelope error. That run keeps going, but it stops sending lines. So a program that writes without end costs no more than the words it already sent.

These arrive while the entry module is composed or linked, and each one reports inside the result envelope’s error. But a dynamic import is the single exception: an import() of a name the map does not carry rejects inside the program, and the program can catch it and try something else.

Class Text What causes it
ReservedProviderNameError Provider name "<name>" is reserved. Give the provider a name the entry scope does not bind. The name collides with a binding the entry scope declares, listed in packages/protocol/src/provider-name.ts.
InvalidProviderNameError Provider name "<name>" must be ASCII letters, digits, underscore and dollar, and must not start with a digit. The name fails PROVIDER_NAME_PATTERN.
ReservedWordProviderNameError Provider name "<name>" is on the list this executor refuses: every ECMAScript reserved word, the names strict mode reserves, and async. Pick a name outside it. The name is in JAVASCRIPT_RESERVED_WORDS.
DuplicateProviderNameError Provider name "<name>" is declared twice. Give each provider its own name. Two descriptors carry one name.
ModuleNotFoundError Module "<name>" is not in the module map. Import one of the names it declares: <declared>. An import, static or dynamic, named something the map does not carry. A request that declared no modules reads This request declared no modules. instead.
ModuleKeyCollisionError Module map keys "<declaredKey>" and "<key>" both resolve to "<resolvedName>". Keep one key for each module. Two map keys normalize to one specifier.
UnsupportedModuleKindError Module "<name>" has no supported kind. Supported kinds: js, cjs, json, text, data. A map entry is an object and carries none of the keys the text names.

Hosts that build provider names out of user data test them against the constants, not against the wording. Both name rules refuse names their texts appear to allow. PROVIDER_NAME_PATTERN is /^[a-zA-Z_$][a-zA-Z0-9_$]*$/, ASCII and no more. The language is far wider: ECMA-262 §12.7 interprets an identifier under the Default Identifier Syntax of Unicode Annex #31. So café binds without complaint in real JavaScript, but this pattern turns it away as no identifier at all.

JAVASCRIPT_RESERVED_WORDS covers the ReservedWord production of ECMA-262 §12.7.2 and the names that only strict mode forbids, and then adds eval, arguments and async. The first two belong there on the language’s own terms, since neither can bind in the strict-mode module the entry compiles to. async is the deliberate one. ECMA-262 says outright that it is not reserved and can be used as a variable name without restriction. This list refuses it anyway, because a const whose name also opens an async function head is too ambiguous to put in front of generated source.

Servers validate provider names in the bridge before leasing a worker, and the sandbox validates them again on every path. Both report the same text, because both call validateProviders.

Tool calls that never reach a tool answer with one of the texts below, and a call whose arguments are too large to send answers with the payload text above instead. Every one of them arrives as a plain Error at the call site, so a program can catch it and try something else. Carriers underneath differ. Tool "<tool>" not found always rides the result envelope, and the generated proxy rethrows it. Where a dispatch builds an envelope the completion text rides it, and where the host answers before any envelope exists it rides the bare dispatch frame. When the answer is no envelope at all, the proxy itself throws the envelope text.

Text What causes it
Tool "<tool>" not found The request carried no dispatch at all, or the host’s own dispatch turned the name down. ToolNotFoundError composes it, and the adapter answers the same text for a name its provider table does not serve.
Execution has already completed. The call landed after the run settled. A handler of a retained execution meets this when it calls a tool. EXECUTION_COMPLETED_MESSAGE in packages/protocol/src/protocol.ts declares it.
The dispatch answered outside the result envelope. The dispatch answered something that is not an envelope object. The generated proxy in packages/executor/src/sandbox/proxy-source.ts composes this one.

Surfaces this runtime does not carry on purpose are still published, and each rejects its first touch. Code that reached for one learns why on the spot, and never meets an undefined that fails somewhere later. The protocol specifies them, and Runtime deviations lists the whole runtime’s deviations.

Class Text Where it is thrown
CompressionAbsentError <name> is absent from this runtime by design. The host applies content coding on the wire, so work with uncompressed bytes. Constructing CompressionStream or DecompressionStream, and reading either attribute on their prototypes.
CacheStorageAbsentError Cache storage is absent from this runtime because nothing survives an execution. Return what should persist in the result and take it back as an input on the next call. Every member of caches, under the persistence contract.
BufferGrowthAbsentError <operation> is absent from this runtime because a buffer that grows after it is created is allocated outside the memory limit this execution is metered against. Ask for the length you need when you create the buffer. ArrayBuffer.prototype.resize and SharedArrayBuffer.prototype.grow, where <operation> is that qualified name.

Text is the contract, and the carrier follows from the boundary that reports it. Hosts that render failures to a model read the message and can ignore the rest of this table. But a host writing tests against the sandbox needs it, because the assertion has to name the type.

Where the failure lands Carrier Example
The result envelope The error field of ExecuteResult, with result absent A timeout, a memory limit, a provider name, a static import of an unmapped name
Inside the sandbox, from fetch A TypeError whose message is the host’s text. Fetch reports a request that never happened this way OutboundDisabledError, and PayloadTooLargeError for an outbound request
Inside the sandbox, from a declared absence A DOMException named NotSupportedError, the web platform’s name for an operation a runtime does not support CompressionAbsentError, CacheStorageAbsentError, BufferGrowthAbsentError
Inside the sandbox, from a tool call A plain Error thrown at the call site Tool "<tool>" not found, Execution has already completed., PayloadTooLargeError for a tool call
Host code, in process or over the wire The thrown instance, rebuilt by rehydrateExecutorError when it carried a code QueueOverflowError, HandlesClosedError

The grammar binds the failures this executor declares and no others. Plenty of text still reaches a caller from outside it, and all of it passes through as it reads. Programs that do not fit the entry contract never compile. V8 reports its own syntax errors. Coordinates from the engine point into the generated entry module and not into the code that was submitted, so a broken arrow answers with something shaped like Unexpected token '=' [executor.js:<line>:<column>].

Whatever user code throws keeps its own words. The envelope reports the message as it reads, and even a program that throws the pinned timeout words keeps them. ExecutionTimeoutError.matches tests the whole first sentence, Execution timed out at its <timeout> ms bound., and not the prefix. So the codemode adapter tells a real timeout from a coincidence before rendering it back down to the sentence its dialect pins.

The runtime speaks its own standards. This sandbox implements fetch, the streams, URL and Web Cryptography, and each reports the message its standard states, not one written to this grammar. Its CommonJS layer does the same in Node’s direction: a require for a name the module map does not carry answers Cannot find module '<id>', the wording Node’s own module loader uses.

The pinned dispatch answers keep their exact shape wherever they are composed. So Tool "<tool>" not found ends without a period, and packages/protocol/src/errors.ts composes it once for every site that turns a tool call down.