> ## Documentation Index
> Fetch the complete documentation index at: https://assemblyai.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Error handling

> Streaming TTS Error frames, WebSocket close codes, HTTP API errors, and how to tell a real failure from a finished session.

This page is about the WebSocket API, whose errors arrive as frames. The [HTTP API](https://assemblyai.com/docs/tts/api-reference/http-api/overview.md) answers errors with HTTP statuses and a JSON body instead; see [HTTP API errors](#http-api-errors), and [The same condition in each API](#the-same-condition-in-each-api) for how the two line up.

Errors in the WebSocket API arrive **in-band, on a socket that opened successfully**. Connecting is not a sign that anything worked: credentials are checked and parameters are validated after the upgrade completes, and both report failure as an `Error` frame followed by a close.

A client that ignores `Error` frames sees the socket open and then close a moment later with no audio, and nothing in its own logs says why: on a failure close the WebSocket close reason is normally the fixed string `See Error message for details`. Only when the `Error` frame could not be delivered, for example to a client that stopped reading, does the close reason carry a shortened copy of its text. Handle `Error` frames before you write anything else.

```json theme={null}
{
  "type": "Error",
  "error_code": 3006,
  "error": "Bad connection parameters: sample_rate: sample_rate 12345 not in supported set [8000, 16000, 22050, 24000, 44100, 48000]",
  "error_type": "invalid_parameter",
  "retryable": false,
  "param": "sample_rate",
  "retry_after_seconds": null
}
```

`error_code` equals the WebSocket close code that follows. The code tells you the category, `error_type` the specific cause as a stable string, and `param` the connection parameter or message field at fault. `retryable` says whether reconnecting with the same parameters can succeed, and `retry_after_seconds`, when it is set, the earliest useful retry. Branch on `error_code` and `error_type`. The `error` text is written for people and may be reworded, so log it and quote it in support requests rather than matching on it.

Every failure close the server initiates is preceded by an `Error` frame, and that frame is the last message of the session: no `Termination` follows it. A few closes carry no `Error` frame at all: the clean `1000` after your own `Terminate`, and the ones the WebSocket layer raises on its own, which are `1006` (the connection was lost), `1009` (a message over 1 MiB), `1011` when a client stops answering pings, and `1002` or `1007` when a client breaks the WebSocket protocol or sends text that is not valid UTF-8. Ignore frame types you do not recognize; new server-to-client types may be added.

Sessions open on the path `/v1/ws` (or `/v1/ws/`). A request on a wrong path, such as `/v1/ws/session` or `/v1/wss`, is answered with HTTP `404` and never becomes a WebSocket, so a client sees a failed handshake rather than an `Error` frame. A request on the session path with any method other than `GET` fails the same way, with HTTP `405` and an `Allow: GET` header, and so do a handshake with two `Authorization` headers and a `GET` that announces a body (a `Transfer-Encoding` header, or a `Content-Length` other than `0`), both with HTTP `400`. On the session path, connect-time checks run in a fixed order: room for the connection on the server it reached (`3005` `at_capacity`, before your credentials are read), credentials (`1008`, or `1011` on an internal error), then connect parameters (`3006`), then your account's limit of new sessions per minute (`3009`), then the session's language and voice: a language no model of the region speaks or an AssemblyAI voice the session's model does not speak (`3006`), with `3005` when the service that routes voices cannot answer. Any other voice in `voice_id`, such as a [cloned voice](https://assemblyai.com/docs/tts/cloned-voices.md), is looked up in this step: `3012` when no voice your account can use has that `voice_id` or the voice cannot be used, `3014` when it is not paired with the session's model, and `3006` `not_owner` when `extend_biometric_consent` is set on a voice that another account owns. Only then does `Begin` arrive. After `Begin`, a session the speech service finds no room for closes `3005` `at_capacity`, in rare cases a voice the session's model does not speak is refused with `3006` right after `Begin` rather than before it, and a cloned voice deleted or changed while the session starts or runs closes it with `3012` or `3014`. Keep handling `Error` frames for the whole session.

## Close codes

| Code | Meaning |
| - | - |
| `1000` | Clean end after your `Terminate`, with reason `Session ended`. No `Error` frame |
| `1002` | The client broke the WebSocket protocol, rejected by the WebSocket layer. No `Error` frame |
| `1006` | The connection was lost without a close handshake: a network drop, a server that was replaced before it could close the session, or what a client that stopped reading sees after a `3011`. No `Error` frame |
| `1007` | A text frame that is not valid UTF-8, rejected by the WebSocket layer. No `Error` frame |
| `1008` | Unauthorized: the credential was missing, invalid or expired, or the account has a problem such as insufficient funds or a disabled key. The `error` text says which |
| `1009` | A WebSocket message over 1 MiB, counted after its fragments are joined, rejected by the WebSocket layer. No `Error` frame |
| `1011` | Internal error while authenticating; transient. Also the WebSocket layer's code when a client does not answer a ping within 60 seconds, which has no `Error` frame |
| `1012` | The server is restarting. It closes sessions between requests when it can. Reconnect; the new session lands on another server |
| `3005` | The service was temporarily unavailable or at capacity, the service that routes voices could not answer, the server hit an internal error, or a `Terminate` drain stalled for 30 seconds with a `FlushDone` still owed |
| `3006` | Bad client input: an invalid connect parameter or one given more than once, a malformed or unknown frame, a binary frame, a `Generate` over 2000 characters, a piece of text that does not fit the model with this voice (rare), a voice this connection does not serve, a language no model of the region speaks, a language a cloned voice does not speak, a `model_id` the region does not know, `extend_biometric_consent` on a voice that another account owns, or cloned voices not available on the host. Also the idle timeout, two minutes without a frame in either direction, which is not an input error |
| `3008` | The session reached `expires_at`, one hour after connect by default |
| `3009` | Your account already started as many sessions as its limit allows in the current one-minute window. Checked at connect, before `Begin` |
| `3010` | Text or control frames sent faster than the server accepts them: more than 8000 characters sent but not yet synthesized, more than 20 `Flush`, `KeepAlive`, `Cancel` or empty `Generate` frames per second sustained, or more than 10,000 messages waiting to be handled |
| `3011` | Audio not read fast enough: 300 seconds of synthesized audio were waiting for delivery, or one write to the socket stayed blocked for 30 seconds. A client that stopped reading usually sees `1006` instead |
| `3012` | The voice named by `voice_id` cannot be used: not found, or a [cloned voice](https://assemblyai.com/docs/tts/cloned-voices.md) expired or blocked |
| `3014` | The cloned voice is not paired with the session's model, or that model is retired |

### Error types

`error_type` refines the close code. New values may be added; treat an unknown one by its close code and `retryable`.

| `error_type` | Close | `retryable` | When |
| - | - | - | - |
| `unauthorized` | `1008` | `false` | The credential was refused |
| `insufficient_funds` | `1008` | `false` | The account has no balance left; the text is `Unauthorized: Insufficient funds` |
| `auth_unavailable` | `1011` | `true` | The credential could not be checked just now |
| `service_restart` | `1012` | `true` | The server is restarting; reconnect |
| `invalid_parameter` | `3006` | `false` | A connection parameter is invalid or given more than once, a `language` outside the [supported values](https://assemblyai.com/docs/tts/voices.md#languages), a `voice_id` that cannot be a voice ID or slug, and a `language` a cloned voice does not speak on the session's model. `param` names the parameter |
| `invalid_message` | `3006` | `false` | A malformed frame, a `Generate` over the length limit, or text with a broken character. `param` names the field at fault as `<frame type>.<field>`, such as `Generate.text` for a text that is missing, not a string, over the length limit or holding a broken character, or `type` when the frame's `type` is missing or unknown. It is `null` when no field is at fault: a binary frame, or text that is not a JSON object |
| `invalid_request` | `3006` | `false` | Any other refused request, such as an AssemblyAI voice the session's model does not speak in the session's language (`param` is `voice_id`) or a supported language no model of the region speaks (`param` is `language`), both before `Begin`, or, rarely and after `Begin`, a piece of text that does not fit the model with this voice (the text begins `Text contains a` and ends `Send shorter sentences.`) |
| `model_not_found` | `3006` | `false` | A `model_id` the region does not know, or, with `voice_id`, no model of the region speaks the session's language: the voice's own, or the `language` you sent |
| `feature_not_enabled` | `3006` | `false` | Cloned voices are not available on this host: `the voice catalog is not enabled in this region, so voice_id names only a preloaded AssemblyAI voice here, such as aai-jane`, with `param` `voice_id`. A session that sent `model_id` without `voice_id` gets `model_id is not enabled in this region`, with `param` `model_id`. Connect to `tts.us.assemblyai.com` or `tts.eu.assemblyai.com`; for a cloned voice, the host of its [data zone](https://assemblyai.com/docs/tts/cloned-voices.md#data-zones) |
| `not_owner` | `3006` | `false` | `extend_biometric_consent` was set on a cloned voice that another account owns; only the voice's owner can [renew its consent](https://assemblyai.com/docs/tts/cloned-voices.md#renew-consent). `param` is `extend_biometric_consent` |
| `biometric_terms_not_accepted` | `3006` | `false` | Your account has not accepted the biometric data terms a cloned voice needs; accept them on the dashboard's data controls page. See [Voice cloning](https://assemblyai.com/docs/tts/cloned-voices.md#errors) |
| `inactivity_timeout` | `3006` | `true` | No frame went in either direction for the session's idle timeout: two minutes, or the shorter `inactivity_timeout` you set |
| `session_expired` | `3008` | `true` | The session reached `expires_at` |
| `rate_limited` | `3009` | `true` | The account's new sessions for this minute are used up; `retry_after_seconds`, 1 to 60, is the time until the next one-minute window starts |
| `input_rate_exceeded` | `3010` | `false` | Input far faster than speech |
| `audio_backlog` | `3011` | `false` | Audio read too slowly |
| `voice_not_found` | `3012` | `false` | No voice with this `voice_id` that your account can use on this host. One text covers a voice that does not exist, was deleted, is another account's private voice or belongs to the other data zone. A bare name such as `jane` is read as the slug of one of your own voices: name an AssemblyAI voice by its library slug, `aai-jane`. When the bare name is an AssemblyAI voice's, the text ends `Did you mean voice_id=aai-jane?` |
| `voice_expired` | `3012` | `false` | The cloned voice's consent period ended, and the voice can no longer be renewed |
| `voice_blocked` | `3012` | `false` | The cloned voice is blocked; contact support |
| `voice_not_paired` | `3014` | `false` | The cloned voice is not paired with the session's model, or that pairing is still in progress: the model named by `model_id`, or the default model of the voice's language. `param` is `model_id` either way. A pairing still in progress clears once it is ready |
| `model_retired` | `3014` | `false` | The session's model, usually the one named by `model_id`, is retired; the text names its successor |
| `voice_service_unavailable` | `3005` | `true` | The service that routes and looks up voices could not answer; `retry_after_seconds` is set when it named a time |
| `at_capacity` | `3005` | `true` | The service has no room for the session. Before your credentials are read when the server your connection reached already holds all the connections it takes (`retry_after_seconds` is `2`), or after `Begin` when the speech service found no room for the session within about 8 seconds (`retry_after_seconds` is 2 to 5). Retry after `retry_after_seconds`, and back off further if it repeats |
| `upstream_unavailable`, `internal_error` | `3005` | `true` | The speech service failed, or the server hit an internal error |

### In the SDKs

The Python SDK raises `TTSError` and the JavaScript SDK `TextToSpeechError`: from `connect()` and the `with` block on a session, and from `synthesize()` and `stream()` over HTTP. Both carry the `Error` frame or the HTTP error body as attributes.

| Python | JavaScript | Holds |
| - | - | - |
| `error_code` | `errorCode` | The `error_type` of an `Error` frame, or the `code` of an HTTP error body, such as `voice_not_found` |
| `status_code` | `status` | The HTTP status; `None` for an error on a session |
| `param` | `param` | The parameter at fault, when there is one |
| `retryable` | `retryable` | Whether the same request can succeed later |
| `retry_after` | `retryAfter` | Seconds to wait, from `retry_after_seconds`, when the server named a time |
| `request_id` | `requestId` | The request or session id to quote in support requests |
| `body` | `body` | The whole frame or error body; on a session, `body["close_code"]` (`closeCode` in JavaScript) is the numeric close code |

The message is `str(error)` in Python and `error.message` in JavaScript.

The program below is complete and runs as written. It reads `Begin` before sending anything, so an `Error` that arrives in its place is handled instead of surfacing as a raw connection error on the first `send()`, and it maps each close code and `error_type` to an exception that says what to do next. The `Error` branch sits inside the normal receive loop on purpose: a WebSocket has one reader, so a separate loop that consumed frames while looking for errors would swallow your audio.

```python theme={null}
import asyncio
import base64
import json
import os
import random

import websockets

URL = "wss://tts.us.assemblyai.com/v1/ws?voice_id=49c443a4-8a1d-5f7e-b70b-98bdbd9823c3&language=english"
HEADERS = {"Authorization": os.environ["ASSEMBLYAI_API_KEY"]}


class AuthError(Exception):
    """1008. Fix the credential or the account; retrying unchanged fails again."""


class BadRequest(Exception):
    """3006 for a bad parameter or frame, 3012 or 3014 for a cloned voice, or 1009. Fix the request first."""


class Transient(Exception):
    """1011, 1012, 3005, 3009, a lost connection, or a failure to connect. Back off, then reconnect."""

    def __init__(self, text, retry_after=None):
        super().__init__(text)
        self.retry_after = retry_after


class Paced(Exception):
    """3010 or 3011. Reconnect, but send text slower or read audio faster."""


class SessionOver(Exception):
    """3008, or an inactivity 3006. Nothing is wrong; open a new session when you need one."""


def raise_for_error(message):
    code, text = message["error_code"], message["error"]
    error_type = message.get("error_type")
    if code == 1008:
        raise AuthError(text)
    if code in (1011, 1012, 3005, 3009):
        raise Transient(text, message.get("retry_after_seconds"))
    if code in (3010, 3011):
        raise Paced(text)
    if code == 3008 or error_type == "inactivity_timeout":
        raise SessionOver(text)
    if code in (3006, 3012, 3014):
        raise BadRequest(f"{error_type}: {text}")
    raise RuntimeError(f"{code}: {text}")


async def speak(text):
    """One session: connect, speak `text` once, return the raw PCM bytes."""
    pcm = bytearray()
    try:
        async with websockets.connect(URL, additional_headers=HEADERS) as ws:
            first = json.loads(await ws.recv())
            if first["type"] == "Error":
                raise_for_error(first)

            await ws.send(json.dumps({"type": "Generate", "text": text}))
            await ws.send(json.dumps({"type": "Flush"}))

            async for raw in ws:
                message = json.loads(raw)
                kind = message.get("type")
                if kind == "Audio":
                    pcm += base64.b64decode(message["audio"])
                elif kind == "FlushDone":
                    await ws.send(json.dumps({"type": "Terminate"}))
                elif kind == "Termination":
                    return bytes(pcm)
                elif kind == "Error":
                    raise_for_error(message)
                # WordBoundaries, and any type you do not recognize: ignore.
    except websockets.ConnectionClosedError as e:
        # No Error frame: 1006 (connection lost), 1011 (ping unanswered) or 1009 (message over 1 MiB).
        code = e.rcvd.code if e.rcvd else 1006
        if code == 1009:
            raise BadRequest("a message over 1 MiB was rejected; split the text")
        raise Transient(f"connection closed ({code}) before Termination")
    except (OSError, TimeoutError, websockets.InvalidHandshake) as e:
        # The socket never opened: DNS, TCP, TLS, or a non-101 upgrade response.
        raise Transient(f"could not connect: {e}")
    # The loop ended without Termination: the server closed the socket, so the session is over.
    raise SessionOver("socket closed before Termination")


async def main():
    for attempt in range(5):
        try:
            pcm = await speak("Hi, thanks for calling. How can I help you today?")
            print(f"got {len(pcm)} bytes of audio")
            return
        except (AuthError, BadRequest) as e:
            raise SystemExit(f"fix before retrying: {e}")
        except Transient as e:
            backoff = min(30, 0.5 * 2 ** attempt) * (0.5 + random.random())
            delay = max(backoff, e.retry_after or 0)
            print(f"Transient: {e}; retrying in {delay:.1f}s")
            await asyncio.sleep(delay)
        except Paced as e:
            delay = min(30, 0.5 * 2 ** attempt) * (0.5 + random.random())
            print(f"Paced: {e}; retrying in {delay:.1f}s")
            await asyncio.sleep(delay)
        except SessionOver as e:
            print(f"session ended: {e}; opening a new one")
    raise SystemExit("gave up after 5 attempts")


asyncio.run(main())
```

## A clean close is still a close

> [!WARNING]
>
> Any close, including a normal `1000`, means the session is over. In a
> browser, every later `send()` on that socket is silently discarded: no
> exception, no audio. Server-side libraries usually raise instead (Python
> `websockets` raises `ConnectionClosedOK`). If your client does not listen
> for the close event, a missed close is indistinguishable from a server that
> has nothing to say.

The server closes `1000` only after your own `Terminate`, once `Termination` has been sent. Anything you send after `Terminate`, even while the socket is still open, is ignored: no error, no audio, and it does not count toward the session totals. If you close the socket yourself without `Terminate`, the server cancels the session at once, discards anything not yet spoken, and sends no `Termination`.

On a socket held open across many requests, the close you will actually see is `3008` when the session reaches `expires_at`, or `3006` with `error_type` `inactivity_timeout` when two minutes pass without a frame in either direction (sooner if you set `inactivity_timeout`), for example because the user went quiet. Neither is a fault. Treat every close as the end of the session: mark the client not ready, and reconnect before the next request rather than discovering the problem when it fails. This matters most in the browser, where a close that goes unnoticed breaks every request that follows.

The handler below gates reconnects on the close code, backs off with jitter, and fetches a fresh temporary token for every attempt: each connection gets a token of its own, so a used or expired token is never sent again. `setReady`, `showError`, `stillNeeded` and `connect` are your own functions; `fetchTemporaryToken` asks your server for a new token as described in [Authenticate with a temporary token](https://assemblyai.com/docs/tts/authenticate-with-a-temporary-token.md).

```javascript theme={null}
const NO_RETRY = new Set([1008, 3006, 3012, 3014]); // fix the credential or the request first
let attempt = 0; // reset to 0 when Begin arrives

ws.onclose = (event) => {
  setReady(false);
  // event.reason normally carries no cause; that was in the Error frame.
  showError(`session closed (${event.code})`);
  if (NO_RETRY.has(event.code)) return;
  if (!stillNeeded()) return; // your own check: the user is done, so no new socket
  // 1000 after your own Terminate, 3008 at expires_at, 1006 on a network drop and the
  // transient codes all end up here: the socket is gone either way, so open a new one.
  const delay = Math.min(30_000, 500 * 2 ** attempt++) * (0.5 + Math.random());
  setTimeout(async () => connect(await fetchTemporaryToken()), delay);
};
```

An idle session also ends with `3006`, after two minutes without a frame in either direction or after the shorter `inactivity_timeout` you set. The handler above does not reconnect then, which is usually what you want: a socket opened at once would sit idle and close again. Open the next socket when the user has something to say, or send `KeepAlive` at least every two minutes while idle to keep the socket ready.

## Retry guidance

* **`1008`** is a credential or account problem, and retrying unchanged will not help. `Unauthorized: missing Authorization header or token query param` means no credential reached the server. `Unauthorized: Invalid API key` means the key or token was rejected; the raw key and `Bearer <key>` both work, so check the key itself. `Unauthorized: Signature has expired` means the temporary token expired: mint a new token and reconnect. A message that names your account or balance, such as `Unauthorized: Insufficient funds`, has to be fixed on the account first.
* **`3006`** for a bad parameter, a malformed frame or an oversized `Generate` means the request is wrong; `error_type` and `param` say what to fix. Fix it before reconnecting; retrying unchanged fails identically. A parameter given more than once reads `Bad connection parameters: <parameter>: must be given at most once (given 2 times)`: send it once. For an AssemblyAI voice this connection does not serve, the `error` text lists the voices that are available on it, by their `voice_id` slugs. A `3006` after `Begin` whose text begins `Text contains a` and ends `Send shorter sentences.` means a piece of your text, split as [How text is read](https://assemblyai.com/docs/tts/prompting-guide.md#how-text-is-read) describes, did not fit the model with this voice. The server splits every text into pieces that fit, so this is rare and points to a fault on our side: report it with the session's `id`. Sending the text again in shorter sentences avoids it.
* **`3012` and `3014`** concern a [cloned voice](https://assemblyai.com/docs/tts/cloned-voices.md#errors), and retrying unchanged fails the same way, except for a pairing still in progress. `3012` is a voice that cannot be used: `voice_not_found` (check the ID, that an AssemblyAI voice is named by its library slug such as `aai-jane`, and that you connect to the host of the voice's data zone), `voice_expired` (create the voice again from a new consented recording; to keep a voice, [renew its consent](https://assemblyai.com/docs/tts/cloned-voices.md#renew-consent) before it expires) or `voice_blocked` (contact support). `3014` `voice_not_paired` means the voice has no ready pairing with the session's model: if the voice was just paired with the model, try again once the pairing is ready; otherwise name a model the voice is paired with in `model_id`, or check the voice in the AssemblyAI dashboard. The `error` text says what to do; [The texts of 3014](#the-texts-of-3014) lists them. A `3006` with `error_type` `not_owner` means `extend_biometric_consent` was set on a voice that another account owns: open the session without it.
* **`3006` from the idle timeout** is not a request error. Every session closes after two minutes without a frame in either direction, or after the shorter `inactivity_timeout` you set. Its `error_type` is `inactivity_timeout`, and the `error` text begins `No message received for` and ends `Send KeepAlive frames to hold an idle session open.` Reconnect when you have something to say, or send `KeepAlive` at least every two minutes while idle if you want the socket to stay open.
* **`1011`, `1012`, `3005` and `3009`** are transient. Back off exponentially with jitter, then reconnect, and wait at least `retry_after_seconds` when the `Error` frame sets it. `1011` is an internal error while authenticating. `1012` (`The server is restarting; reconnect to continue the session.`) means the server is being replaced; the next session lands on another one. `3005` is a failure on the server side, for example `The TTS service is temporarily unavailable; please reconnect and retry`, or `Voice service temporarily unavailable; please retry.` when the service that routes voices cannot answer at connect; re-send the request that was in flight. A `3005` with `error_type` `at_capacity` means the service had no room for the session: `This server is at capacity; please retry in 2 seconds.` before your credentials are read, or `The TTS service is at capacity; please retry in 3 seconds.` after `Begin`. Wait `retry_after_seconds` before reconnecting, and back off further if it repeats. `3009` (`Too many sessions opened for this account in the last minute (allowance 100 per minute); retry in 17 s.`) means your account started more sessions in the current one-minute window than its limit allows, and `retry_after_seconds`, between 1 and 60, is the time until the next window starts: a session opened after that wait is counted in the new window. As for Streaming Speech-to-Text, the limit counts new sessions per minute, not sessions currently open, so closing sessions early does not help; opening fewer does. A voice agent call opens one session, so the limit is the number of calls that can start in a minute. The number in the text is your starting limit; on a paid plan the limit in force can be higher. Keep one socket open and send many requests through it rather than opening a new socket for every utterance. Your limit is your Streaming Speech-to-Text limit, shown on the dashboard, counted separately from your speech-to-text sessions. See [Rate limits](https://assemblyai.com/docs/streaming/rate-limits.md) for how limits scale with usage.
* **`3010` and `3011`** mean pacing, not a broken request. `3010` has three triggers: more than 8000 characters sent but not yet synthesized (`Input text rate exceeded: ...`), more than 20 `Flush`, `KeepAlive`, `Cancel` or empty `Generate` frames per second sustained (`Control frame rate exceeded: ...`), or more than 10,000 messages waiting to be handled. Send text at roughly the pace it is spoken and control frames only as the conversation needs them. `3011` means 300 seconds of synthesized audio were waiting for a client that had stopped reading; read from the socket at least as fast as the audio plays. Such a client usually cannot receive the `Error` frame either and sees `1006` when the socket is torn down. In both cases reconnect, but do not resend the same burst. See [Limits](https://assemblyai.com/docs/tts/limits.md).
* **`3008`** (`Session reached its maximum duration; open a new connection to continue`) is routine on a long-lived socket, not a fault. Sessions last one hour by default; `Begin.expires_at` gives the exact time, and a temporary token with a shorter `max_session_duration_seconds` brings it forward. At `expires_at` the server stops taking new input, speaks what you have already sent, then closes. Read `expires_at` and open a replacement socket a little before it so the next request has somewhere to go.
* **`1006`** is the connection being lost without a close handshake, on your side or because the server was replaced. Reconnect.
* **`1009`** means a message over 1 MiB, counted after its fragments are joined. A well-formed client cannot reach it, because `Generate` is capped at 2000 characters; check what else you are putting in the message.

Back off before every reconnect that follows a failure, whatever the code. Every reconnect that gets past the credential and connect parameter checks counts as a new session start, so a tight retry loop, for example after a `3005`, can turn a transient failure into a `3009`.

Every retry is a new session, and a close loses whatever the old one had not finished: text buffered but not yet synthesized, flushes still in flight, and audio not yet delivered. Re-send unfinished requests on the new socket. Because configuration is fixed at connect time, build the connect URL in one function that takes a freshly minted token, so a reconnect cannot drift from the original configuration and a used or expired token is never sent again.

## HTTP API errors

The [HTTP API](https://assemblyai.com/docs/tts/api-reference/http-api/overview.md) reports an error before the first audio as an HTTP status with a JSON body: its own body on `/v1/speech` and `/v1/speech/events`, described in [HTTP API basics](https://assemblyai.com/docs/tts/api-reference/http-api/overview.md#errors), and OpenAI's body on the [OpenAI-compatible route](https://assemblyai.com/docs/tts/openai-compatibility.md#errors). Its `code` is the WebSocket API's `error_type` wherever the WebSocket API has one, so one condition carries one name on every route. `retryable` and `Retry-After` say whether and when to try again.

The status is sent together with the first audio, so every failure before it is a real HTTP status. A failure after the first audio cannot change the status any more: `/v1/speech` and the OpenAI-compatible route cut the connection before the end of the body, and `/v1/speech/events` ends its stream with an `Error` event. Retry a cut request from the start. A cut caused by a server failure is not billed. [HTTP API basics](https://assemblyai.com/docs/tts/api-reference/http-api/overview.md#error-codes) lists every code with its status.

## The texts of 3014

The `error` text of a `3014` says what to do. In these texts, `<voice ID>` is the cloned voice's UUID, also when you sent its slug, `<model>` is the model's name, `<reason>` is a short code for the cause, and `<successor>` is the model that replaces a retired one, or `the current model` when none is named. The HTTP API and the OpenAI-compatible route answer with the same texts.

| `error_type` | When | `error` |
| - | - | - |
| `voice_not_paired` | To the voice's owner: the voice is not paired with the model, or the pairing is still in progress | `Voice <voice ID> has no ready pairing with model <model>. Name a model the voice is paired with, or pair the voice with this model, wait until the pairing is ready, and try again.` |
| `voice_not_paired` | To the voice's owner: the voice's recording was refused for the model | `Voice <voice ID> cannot be paired with model <model>: its reference clip was refused (<reason>), and pairing it again fails the same way. Create a new voice from another clip.` |
| `voice_not_paired` | To the voice's owner: the pairing failed for another reason | `Voice <voice ID>'s pairing with model <model> failed (<reason>) and is not retried. Remove the pairing, pair the voice with this model again, wait until the pairing is ready, and try again.` |
| `voice_not_paired` | To any other account | `Voice <voice ID> has no ready pairing with model <model>, and only its owner can pair it. Name a model the voice is paired with, or use another voice.` |
| `voice_not_paired` | To any other account, when the pairing failed | `Voice <voice ID> cannot be used with model <model>: its pairing failed, and only its owner can pair it again. Name a model the voice is paired with, or use another voice.` |
| `model_retired` | To the voice's owner | `Model <model> is retired; pair the voice with its successor <successor> and try again.` |
| `model_retired` | To any other account | `Model <model> is retired, and only the voice's owner can pair the voice with its successor <successor>. Name a model the voice is paired with, or use another voice.` |
| `model_retired` | When the refusal does not say whose the voice is | `Model <model> is retired. The voice's owner can pair the voice with its successor <successor>; anyone else can name a model the voice is paired with, or use another voice.` |

## The same condition in each API

| Condition | WebSocket API | HTTP API | OpenAI-compatible route |
| - | - | - | - |
| No credential | `1008` `unauthorized` | `401` `missing_authorization` | `401` `authentication_error` |
| Credential rejected | `1008` `unauthorized` | `401` `unauthorized` | `401` `authentication_error` |
| No funds | `1008` `insufficient_funds` | `402` `insufficient_funds` | `402` `insufficient_quota` |
| Credential could not be checked | `1011` `auth_unavailable` | `503` `auth_unavailable` | `503` `service_unavailable_error` |
| A field or parameter is invalid | `3006` `invalid_parameter` | `422` `invalid_parameter` | `400` `invalid_request_error` |
| A malformed message or body | `3006` `invalid_message` | `400` `invalid_message` | `400` `invalid_request_error` |
| A voice or language the model does not serve | `3006` `invalid_request` | `422` `invalid_request` | `400` `invalid_request_error` |
| The biometric data terms are not accepted | `3006` `biometric_terms_not_accepted` | `403` `biometric_terms_not_accepted` | `403` `permission_error` |
| A voice not found, or a cloned voice expired or blocked | `3012` `voice_not_found`, `voice_expired`, `voice_blocked` | `422`, same code | `400` `invalid_request_error` |
| A cloned voice not paired with the model, or a retired model | `3014` `voice_not_paired`, `model_retired` | `422`, same code | `400` `invalid_request_error` |
| `extend_biometric_consent` on a voice that another account owns | `3006` `not_owner` | `422` `not_owner` | `400` `invalid_request_error` |
| A `model_id` the region does not know | `3006` `model_not_found` | `422` `model_not_found` | `400` `invalid_request_error` |
| Cloned voices not available on the host | `3006` `feature_not_enabled` | `400` `feature_not_enabled` | `403` `permission_error` for a voice sent as `{"id": ...}` or in `voice_id`, otherwise `400` `invalid_request_error` |
| The service that looks up voices could not answer | `3005` `voice_service_unavailable` | `503` `voice_service_unavailable` | `503` `service_unavailable_error` |
| Too many sessions or requests this minute | `3009` `rate_limited` | `429` `rate_limited` | `429` `rate_limit_error` |
| No capacity just now | `3005` `at_capacity` | `503` `at_capacity` | `429` `rate_limit_error` |
| The speech service failed | `3005` `upstream_unavailable` | `503` `upstream_unavailable` | `503` `service_unavailable_error` |
| The server is restarting | `1012` `service_restart` | `503` `service_restart` | `503` `service_unavailable_error` |
| Internal error | `3005` `internal_error` | `500` `internal_error` | `500` `server_error` |
| Past the maximum duration | `3008` `session_expired` | `session_expired`, after the first audio | The connection is cut |

On the OpenAI-compatible route the `code` field holds the HTTP API's code; the column shows the status and OpenAI's `type`.

## Related

* [Message frames](https://assemblyai.com/docs/tts/message-frames.md): the `Error` frame and where it sits in the frame order.
* [Limits](https://assemblyai.com/docs/tts/limits.md): the thresholds behind `3006`, `3008`, `3009`, `3010` and `3011`.
* [WebSocket API basics](https://assemblyai.com/docs/tts/connect-parameters.md): `inactivity_timeout`, and the parameters a `3006` can name.
* [Voice cloning](https://assemblyai.com/docs/tts/cloned-voices.md#errors): the errors of a session with a cloned voice, and what to do about each.
* [Authenticate with a temporary token](https://assemblyai.com/docs/tts/authenticate-with-a-temporary-token.md): minting the token a browser client reconnects with.
* [HTTP API basics](https://assemblyai.com/docs/tts/api-reference/http-api/overview.md#errors): the error bodies and codes of the HTTP API.

## Need help?

If you get stuck, contact our support team at
[support@assemblyai.com](mailto:support@assemblyai.com) or create a
[support ticket](https://www.assemblyai.com/contact/support). Include the `id`
from the `Begin` frame, or the timestamp of the connection attempt if the
session never reached `Begin`.
