TTS
Errors and retries
What each close code means, and which of them are worth retrying.
The gateway refuses a connection by accepting the socket and closing it with a specific code. An HTTP status is invisible to a WebSocket client, so the close code is the error. Read event.code, not onerror, which by design carries nothing.
Close codes#
| Code | Meaning | What to do |
|---|---|---|
4001 | Bad or missing credential. The token was forged, or it expired before you opened it. | Fail loudly. Mint a new session before assuming the key is bad. |
4002 | You are at your plan’s concurrent-session limit. | Back off with jitter, or queue. Yours to fix. |
4003 | A malformed message, or a voice this key cannot use. | Terminal. Fix the bug; never retry. |
4004 | Out of API credits. | Terminal. Surface it to whoever pays the bill. |
4029 | Rate limited. | Honour Retry-After. |
1008 | You stopped consuming audio and the outbound queue overflowed. | Fix the consumer. See below. |
1013 | No capacity right now. Ours, not yours. | Retry after 1–2 s with backoff. |
1011 | Internal error mid-stream. | Retry once immediately. |
1000 | An ordinary close. | Nothing. This is not an error. |
Never merge 1013 and 4002. The first is our fleet having no free worker; the second is your account over its own limit. A client that collapses them tells a developer with no sessions running to upgrade their plan, which is an invitation to pay us to fix our own outage.
Handling them#
type Failure =
| "badKey" // 4001: the credential is wrong or expired
| "voice" // 4003: the voice is not this key's to use
| "atLimit" // 4002: the account is at its concurrency ceiling
| "rateLimited" // 4029
| "capacity" // 1013: OURS, not theirs. Never says "upgrade".
| "quota" // 4004: out of credits
| "server" // 1011, or an `error` message mid-stream
| "network"; // never opened, or dropped
/** 1000 and 1005 are an ordinary close and never a failure. */
export function closeFailure(code: number): Failure | null {
switch (code) {
case 1000: case 1005: return null;
case 4001: return "badKey";
case 4003: return "voice";
case 4002: return "atLimit";
case 4029: return "rateLimited";
case 1013: return "capacity";
case 4004: return "quota";
case 1011: return "server";
default: return "network";
}
}
const RETRIABLE = new Set<Failure>(["capacity", "server", "network", "rateLimited"]);
socket.onclose = (event) => {
const failure = closeFailure(event.code);
if (!failure) return; // a clean close
if (RETRIABLE.has(failure)) scheduleRetry(); // backoff below
else surfaceToUser(failure); // terminal: stop
};Retry policy#
- Retriable:
1013,1011, and a socket that never opened. Exponential backoff from 200 ms with jitter, at most three attempts. - Back off, do not hammer:
4002and4029. A retry storm against a concurrency ceiling is just a slower failure. - Terminal:
4001,4003,4004. Nothing about the account or the request changes because you asked again.
One nuance on 4001: the connect token lives about thirty seconds. A socket you opened late is refused exactly like a forged one, and the two are deliberately indistinguishable to a client. Before deciding your key is bad, mint a fresh session and open it promptly.
Backpressure and 1008#
Each session’s outbound queue is bounded at about two seconds of audio. Past that, frames are dropped, and after a sustained run the socket closes with 1008.
That trade is made deliberately: dropping audio for one slow client is bad, and stalling a shared decode batch is worse, because it degrades every other customer on that GPU. If you see 1008, the fix is on your side: hand frames to a player or a buffer immediately and do the work somewhere other than the socket’s message handler.
Worker loss and recovery#
If the GPU serving your session disappears, the gateway rebinds to another and replays. It still holds the text of every queued segment and knows which one was in flight. The in-flight segment is resynthesised from its start, so a listener hears at most one segment repeated rather than a gap. Past a threshold of it having already played, recovery resumes at the next boundary instead.
You will see a resumed message. No action is needed; it is there so your logs can explain a repeated phrase.
HTTP errors from the session route#
Everything checked before a socket exists arrives as a status instead. Those are listed under Authentication and on the session endpoint.
