Error Handling
Maelstrom surfaces failures through a small, deliberate set of channels. This guide shows you where each kind of error lands and how to build UI around it.
The three failure surfaces
Section titled “The three failure surfaces”Frame your handling around where the failure originates. Each surface has a distinct channel:
| Surface | Channel | What it covers |
|---|---|---|
| Engine / processing | useMaelstrom().lastError (plus onError) |
A processIntent call fails: the engine rejects, the request times out, or response handling throws. |
| Connector | useMaelstrom().connectionStatus + connectionError (plus onError on initial connect) |
The transport fails to connect or the Orchestrator fails to start. Mid-session drops update connectionStatus/connectionError only; they do not reach onError. |
| Vessel runtime | The built-in ErrorBoundary |
A lazy vessel’s render throws or its chunk fails to load. Eagerly-registered vessels rely on your own React error boundary. |
The first two are reactive state you read in your own components. The third is automatic and internal, covered below.
Catching engine and connector errors
Section titled “Catching engine and connector errors”Two entry points cover both surfaces.
The Provider onError callback
Section titled “The Provider onError callback”<m.Provider> accepts an onError prop that fires for connection failures, Orchestrator startup failures, processIntent failures, and failed executeAction calls. It receives a single Error:
import { m } from './maelstrom';import { myConnector } from './connector';
export default function App() { return ( <m.Provider connector={myConnector} onError={(error) => { // error is always a plain Error. Log it, report to telemetry, etc. console.error('[Maelstrom]', error); }} > <m.Chart /> </m.Provider> );}onError is the right place for side effects: logging, toasts, error reporting. It is not where you render fallback UI; for that, read the state with useMaelstrom().
Reading error state with useMaelstrom()
Section titled “Reading error state with useMaelstrom()”useMaelstrom() exposes the reactive error state you render against:
| Field | Type | Description |
|---|---|---|
lastError |
Error | null |
The last error from processIntent. Auto-clears on the next call. |
connectionStatus |
ConnectionStatus |
One of 'disconnected', 'connecting', 'connected', 'error'. |
connectionError |
Error | null |
The last connection error, set when connectionStatus is 'error'. |
processIntent never rejects; it always resolves, and a failure shows up as a populated lastError instead. So you don’t need a try/catch around the call; read lastError afterwards.
import { useState } from 'react';import { m } from '../maelstrom';
export default function IntentBar() { const { processIntent, isProcessing, lastError, connectionStatus, connectionError } = m.useMaelstrom(); const [intent, setIntent] = useState('');
const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!intent.trim() || isProcessing) return; // processIntent never rejects; inspect lastError after it resolves. await processIntent(intent); setIntent(''); };
return ( <div> {connectionStatus === 'error' && ( <div role="alert"> Lost connection to the engine. {connectionError && <span> {connectionError.message}</span>} </div> )}
{lastError && ( <div role="alert">Couldn't process that: {lastError.message}</div> )}
<form onSubmit={handleSubmit}> <input type="text" value={intent} onChange={(e) => setIntent(e.target.value)} disabled={isProcessing || connectionStatus !== 'connected'} /> <button type="submit" disabled={isProcessing || !intent.trim()}> {isProcessing ? 'Working...' : 'Send'} </button> </form> </div> );}Gate input on connectionStatus === 'connected' so users aren’t typing into a dead session, and use connectionStatus (not just connectionError) to drive the banner, since 'connecting' is a normal transient state, not a failure.
Vessel runtime errors
Section titled “Vessel runtime errors”Rendering a vessel can throw errors. Maelstrom catches them so one broken vessel can’t take down the whole Chart.
This is automatic and internal: there is nothing to wire up, and almost nothing to configure:
- The Chart wraps each lazy vessel in a built-in
ErrorBoundary. If a lazy component’s chunk fails to load or its render throws, the boundary catches it. - The fallback is a built-in error card (
VesselLoadError) showing “Failed to load” plus a Retry button. Retry works by incrementing an internal key, which produces a freshReact.lazy()instance and re-attempts the import. - The same caught error is forwarded to your Provider
onErrorcallback, so you still get to log or report it.
Retry UX
Section titled “Retry UX”There is no single global “retry”, recovery is per-surface:
- Connector errors. When
connectionStatus === 'error', recovery depends on your connector’s reconnect behavior. Surface the'error'state in your UI and give the user a way to retry the action once status returns to'connected'. - Lazy vessel loads. The built-in
VesselLoadErrorcard already renders a Retry button that re-imports the failed chunk. You get this for free; see Lazy Loading for how lazy entries are registered. - Failed intents. Because
lastErrorauto-clears on the nextprocessIntentcall, the simplest user-facing affordance is letting the user resubmit, since the stale error clears itself on the retry.
The throughline: keep a working input path visible whenever an error is on screen, so the user always has a way forward.
Recovery decision tree
Section titled “Recovery decision tree”flowchart TD
Failure["Failure appears"]
Connection{"connectionStatus<br/>is error?"}
LastError{"lastError<br/>is set?"}
LazyFallback{"Lazy vessel fallback<br/>appears?"}
Disconnected["Show disconnected state,<br/>wait for reconnect,<br/>keep retry visible"]
Resubmit["Let the user edit<br/>or resubmit the intent"]
Retry["Use built-in Retry card<br/>or report through onError"]
Failure --> Connection
Connection -->|"yes"| Disconnected
Connection -->|"no"| LastError
LastError -->|"yes"| Resubmit
LastError -->|"no"| LazyFallback
LazyFallback -->|"yes"| Retry
The useful rule is to keep a working input path visible. Users should never have to refresh the page just to recover from a failed intent or transient connector issue.
Reference surfaces: MaelstromProviderProps, MaelstromState, and DynamicVesselHandle.
Next steps
Section titled “Next steps”- Lazy Loading Vessels — register lazy vessels and customize their loading and error states
- Creating Vessels — the
greetingVesselsetup this guide builds on - The Engine — how the engine validates and heals responses, the source of many processing errors