Errors
Every A2 error code, when it's thrown, whether it's retryable, and how it crosses the wire.
A2Error
A2 failures carry an A2Error with a stable code. The session client
exposes A2ClientError, which extends A2Error, for server failures and local
ones alike, so instanceof A2Error matches both.
// e.g. in a route handler:
import { A2Error } from 'experimental-a2'
export async function POST(req: Request) {
const { orderId, payload } = await req.json()
try {
const events = await ordersServer
.session(orderId)
.append({ type: 'created', payload })
return Response.json(events)
} catch (err) {
if (err instanceof A2Error && err.code === 'INVALID_PAYLOAD') {
// err.details carries the schema issues, in Standard Schema format
return Response.json({ issues: err.details }, { status: 400 })
}
throw err
}
}The codes
| Code | Thrown when | Retryable |
|---|---|---|
INVALID_PAYLOAD | a payload fails its schema; nothing was written. details carries the issues in Standard Schema format | no |
FORBIDDEN | server.fetch authorization denies a parsed stream, history read, push, or presence update | no |
UNKNOWN_EVENT_TYPE | an event type isn't in the contract's events map | no |
UNKNOWN_PRESENCE_FIELD | a presence field isn't in the contract's presence map | no |
PRESENCE_NOT_SUPPORTED | the contract declares presence but the store has no presence capability. Thrown at construction; for environment-resolved stores, at first store use | no |
PARTIAL_DUPLICATE_BATCH | a batch mixes already-appended and fresh events, contains the same id twice, or uses an id already used in a different session (event ids are globally unique). Always a caller bug; nothing was written | no |
SUPERSEDED_ATTEMPT | a handler append or clear comes from an attempt that a recovery claim or a session clear replaced; nothing was written. Also the abort reason on that handler's ctx.signal | no |
SESSION_CLEARED | a state read reaches below the latest clear, a handler reads state past a clear of its event, or an actor call is cleared before it receives its answer | no |
CLAIM_EXPIRED | never thrown; only the ctx.signal abort reason when the attempt's lease lapsed without renewal (assumed loss, decided locally, not proof) | no |
STORE_UNAVAILABLE | the store backend failed; nothing was written. The original error is the cause | yes |
STORE_NOT_CONFIGURED | production boots with no store configured (during next build page collection, deferred to first use), a store's optional peer dependency is missing, or a Postgres database lacks A2's tables at the version the release needs (or the notification triggers listenConnectionString uses). The message names the fix | no |
Retrying an identical batch is not PARTIAL_DUPLICATE_BATCH: it's an
idempotent success that returns the previously appended events. A failing
handler never makes append throw; handler failures retry and, if persistent,
dead-letter the event. See
What append never throws for.
A2ClientError
Import A2ClientError from experimental-a2/client. Connection, push, and
history read failures, and local A2 validation failures, use this class. Server
failures keep their code, message, and details; unexpected thrown values
are kept as cause. Invalid API arguments can still throw a native TypeError
or RangeError.
| Client code | Meaning |
|---|---|
TIMEOUT | A stream stall (phase: 'stream', 35 seconds of silence after connection) or an unconfirmed multiplexed subscription (phase: 'subscribe', 35 seconds without confirmation). timeoutMs is the expired deadline. |
HTTP_ERROR | An unsuccessful HTTP response without a recognized A2 error body. status carries the HTTP status. |
CONNECTION_FAILED | The transport could not establish or retain a connection. WebSocket close events keep closeCode and closeReason when available. |
UNKNOWN | An unexpected client failure. The original value is the cause. |
- HTTP failures keep
statuseven when they carry a recognized server code. - A browser WebSocket handshake rejection has no readable HTTP status or body,
so it stays
CONNECTION_FAILED. A2 never infers an authorization failure or a timeout from an opaque socket error. - A2 sets no initial connection deadline for HTTP or single-session WebSockets.
- A clean stream end is not an error: the reconnection callback and the
connection snapshot carry
null. - Pushes retry
STORE_UNAVAILABLEand client transport failures, up to three attempts. No other server code is retried. - The reconnection policy controls only the live stream. Pausing the stream preserves the writer, but a push can still fail while its WebSocket is unavailable.
NonRetriableError
Throw it (or a subclass, matched by instanceof) from a handler when the
failure is deterministic: a guard that refuses this attempt refuses every
retry identically. The event dead-letters immediately instead of consuming the
ten-failure retry budget. Any other thrown error keeps the
ordinary retry contract.
import { z } from 'zod'
import * as a2 from 'experimental-a2'
import { NonRetriableError } from 'experimental-a2'
import { createServer } from 'experimental-a2/server'
const orders = a2.contract({
name: 'orders',
events: { created: z.object({ chargeable: z.boolean() }) },
})
export const ordersServer = createServer({
contract: orders,
handlers: {
created: async ({ event }) => {
if (!event.payload.chargeable) {
throw new NonRetriableError('this order cannot be charged')
}
// transient failures below retry as usual
},
},
})Over the wire
server.fetch owns HTTP serialization. The push route sends an A2Error as:
{ "error": { "code": "INVALID_PAYLOAD", "message": "...", "details": [] } }with a mapped status:
| Status | Codes |
|---|---|
| 400 | INVALID_PAYLOAD, UNKNOWN_EVENT_TYPE, PARTIAL_DUPLICATE_BATCH |
| 403 | FORBIDDEN |
| 409 | SUPERSEDED_ATTEMPT |
| 503 | STORE_UNAVAILABLE |
- Unknown server exceptions become
STORE_UNAVAILABLE; their private causes are not sent to clients. - The session client deserializes the body into an
A2ClientError, so client and server code branch on identical server codes. - Multiplexed WebSocket subscription failures carry the same envelope in their
unsubscribedframe, alongside the legacyreasonstring. Older reason-only frames read asCONNECTION_FAILED.