Skip to content

Error codes and texts

A fault rejects the call: something under the execution broke, and the error carries a code the calling application branches on. Declared failures are text written for the model whose code just ran, and they arrive in the result envelope or as a rejection the program catches without ever leaving the sandbox. The failure grammar draws the line between the two.

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

A host tells a fault from a declared failure 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. A host branches on the code, and retryable says whether a retry can work."]
  coded -->|"no"| handle["A handle failure. It has a pinned text and no code."]
  envelope -->|"yes"| declared["A declared failure. The text is written for the model to act on."]
  envelope -->|"no"| fine["The run finished. result is the value the arrow resolved to."]
  fault --> codes["Fault codes"]
  handle --> surface["Handle failures without a code"]
  declared --> texts["Declared failures a model reads"]

A program can catch a declared failure inside the sandbox before it reaches the envelope, and these texts are written so that it can. fetch with outbound closed rejects with a text saying to ask through a tool call, and a program that catches it can make that tool call instead.

Every fault carries a code from the taxonomy in packages/protocol/src/errors.ts. A code is declared there only while a consumer can meet it on a thrown error, so failures that reach the model as text carry none.

Codes cross a process boundary and an RPC boundary as an own property of the error. A class crosses neither. capnweb carries the pool’s traffic to its children over the IPC channel and the remote executor’s traffic over a WebSocket, and it passes an Error by value: the own properties survive, and the class is rebuilt from the name. Both arrangements read that code back and construct the class again before they throw. So a fault carrying a code arrives as ExecutorError or one of its subclasses, and a failure composed without a code arrives as a plain Error whose name reads Error and whose message is the only thing on it. Below, invokeHandle shows both: a name outside the grant rejects with a plain Error, and a closed grant rejects with HandlesClosedError. The child composes both texts, so a host branches on code and not on where the failure was built. rehydrateExecutorError builds the class back for whatever else arrives in 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. The 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. When a shutdown cut work the pool had accepted but not started Reads retryable. Work the pool had accepted and a shutdown cut before it started goes to another replica. Reports the rest: nothing a caller changes about the request makes it go away.

Fault messages are written for whoever reads the incident afterwards, and a branch reads the code instead. Where the caller can do something about the failure, the class composes its message to the same grammar the declared failures use below, and a model reads 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>. invokeHandle was given a name outside the grant. GRANTED_HANDLES in packages/executor/src/sandbox/handles.ts is the granted list, and it contains fetch. The handles keep serving.
Execution "<executionId>" has no handle named "<handleName>". That execution granted no handles. HandleNotFoundError composes this second sentence when the granted list is empty. GRANTED_HANDLES is never empty, so only a caller constructing the class itself reads this text.
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.

An invocation that outlives its timeout or exhausts the memory limit rejects with the timeout or memory text of the declared failures below, and the pool closes the handles with it. Disposing the isolate stops the invocation.

Each declared failure is written as two sentences: the first names what failed and why, the second says what remains possible. The instruction is already in the message, in the words a model should read, so a host renders the text and adds none of its own. Pinned dispatch answers under Tool calls are the exception, because the dialect fixes their wording and this grammar does not.

Programs meet these failures by asking for more than the execution was granted. Each text names the bound the execution 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.

Composing or linking the entry module throws these, and each one reports inside the result envelope’s error. But a dynamic import is the exception: an import() of a name the map does not carry rejects inside the program, so 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 share 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 a non-empty map does not carry.
ModuleNotFoundError Module "<name>" is not in the module map. This request declared no modules. The same import, on a request that declared no modules, reads this text 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.

A tool call that never reaches a tool rejects with one of the texts below, and a call whose arguments are too large to send rejects with the payload text above instead. Each one arrives as a plain Error at the call site, so a program can catch it and try something else. The carriers underneath differ. Tool "<tool>" not found always travels in the result envelope, and the generated proxy rethrows it. The completion text has two carriers, because the caller’s dispatch never sees that call. Guards in front of that dispatch build the envelope, createEnvelopeDispatch in packages/protocol/src/codec.ts for a host that passes a function and forwardDispatch in the server bridge for one that passes a stub. Where the sandbox’s own dispatch reference turns the call back first, packages/executor/src/sandbox/dispatch.ts returns thrown with the bare text and builds no envelope. The proxy throws the envelope text itself when a dispatch returns something that is not an envelope object.

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 throws the same error for a name its provider table does not list.
Execution has already completed. The call arrived after the run finished. 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 returned something that is not an envelope object. The generated proxy in packages/executor/src/sandbox/proxy-source.ts composes this one.

Surfaces this runtime leaves out on purpose are still published, and each fails at its first touch. Code that reaches 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 a program meets it
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 throws, and so does 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 is async, so each one returns a rejected promise. The persistence contract says why.
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 throw, and <operation> is the qualified name of whichever one was called.

The boundary that reports a failure picks its carrier. Hosts that render failures to a model read the message and can ignore this table. But a host writing tests against the sandbox needs it, because the assertion has to name the type.

Where the failure arrives 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. Text that comes from outside it still reaches a caller, and 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 reports 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.

This sandbox implements fetch, the streams, URL and Web Cryptography, and each reports the message its own 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 throws 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.