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

CodeThrown whenRetryable
INVALID_PAYLOADa payload fails its schema; nothing was written. details carries the issues in Standard Schema formatno
FORBIDDENserver.fetch authorization denies a parsed stream, history read, push, or presence updateno
UNKNOWN_EVENT_TYPEan event type isn't in the contract's events mapno
UNKNOWN_PRESENCE_FIELDa presence field isn't in the contract's presence mapno
PRESENCE_NOT_SUPPORTEDthe contract declares presence but the store has no presence capability. Thrown at construction; for environment-resolved stores, at first store useno
PARTIAL_DUPLICATE_BATCHa 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 writtenno
SUPERSEDED_ATTEMPTa 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.signalno
SESSION_CLEAREDa 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 answerno
CLAIM_EXPIREDnever thrown; only the ctx.signal abort reason when the attempt's lease lapsed without renewal (assumed loss, decided locally, not proof)no
STORE_UNAVAILABLEthe store backend failed; nothing was written. The original error is the causeyes
STORE_NOT_CONFIGUREDproduction 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 fixno

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 codeMeaning
TIMEOUTA 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_ERRORAn unsuccessful HTTP response without a recognized A2 error body. status carries the HTTP status.
CONNECTION_FAILEDThe transport could not establish or retain a connection. WebSocket close events keep closeCode and closeReason when available.
UNKNOWNAn unexpected client failure. The original value is the cause.
  • HTTP failures keep status even 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_UNAVAILABLE and 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:

StatusCodes
400INVALID_PAYLOAD, UNKNOWN_EVENT_TYPE, PARTIAL_DUPLICATE_BATCH
403FORBIDDEN
409SUPERSEDED_ATTEMPT
503STORE_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 unsubscribed frame, alongside the legacy reason string. Older reason-only frames read as CONNECTION_FAILED.