---
title: Errors
description: 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.

```ts
// 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](https://standardschema.dev) 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](/server/guarantees#when-a-handler-keeps-failing). See
[What append never throws for](/server/guarantees#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 `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](/client/transports#lifecycle) 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.

```ts
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:

```json
{ "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
  `unsubscribed` frame, alongside the legacy `reason` string. Older reason-only
  frames read as `CONNECTION_FAILED`.
