Limits, errors & 503¶
Request limits¶
| Limit | Value | Response when exceeded |
|---|---|---|
| Text length | 4 000 characters (after trimming) | 413 {"error":"text longer than 4000 characters"} |
| Chunks per request | 64 (text is split into ≤ 50-token sentence chunks) | 413 {"error":"text splits into N chunks; max 64"} |
| Sample rate | 24 000 only | 400 |
speed |
1.0 only (the model has no rate control) |
400 |
temperature |
0 – 2 (default 0.3) | 400 |
| Request timeout | 120 s end to end | stream ends; WS sends {"type":"error","error":"timeout"} |
| Active keys per organisation | 50 | portal refuses to create more |
There is no per-key rate limit. Concurrency is the real limit: one stream per worker, one worker per vCPU, N nodes. Requests that find every worker busy wait in a short queue (up to 8 requests, at most 2 s); past that they are refused rather than delayed.
Status codes¶
| Status | Meaning | What to do |
|---|---|---|
200 |
audio follows (chunked) | play / save; x-request-id identifies the request in your usage log |
400 |
bad request: unknown voice, bad format, nothing left to say after normalisation, bad temperature/speed/sample_rate |
fix the request; do not retry as-is |
401 |
missing, unknown or revoked key | check x-api-key; rotate in the portal |
403 |
voice not in this key's allow-list | use an allowed voice or a different key |
413 |
text too long or too many chunks | split the text (sentence boundaries work best) |
503 + Retry-After: 1 |
every worker busy and the queue full, or the queue wait exceeded 2 s | retry after Retry-After seconds with jitter; nothing was billed |
500 |
engine error | retry once; report the x-request-id if it repeats |
Errors are JSON: {"error": "<message>"}. Status-code semantics are the same on the WebSocket, as an event:
{"type":"error","request_id":…,"status":503,"retry_after":1,"error":"busy"} (validation errors carry their status too).
The 503 contract¶
We would rather tell you now than make your caller wait. When capacity is exhausted:
- REST returns
503 Service UnavailablewithRetry-After(seconds, currently1) before any audio, so your code can fall back or retry cleanly. A stream that has started is never cut short for capacity reasons. - WebSocket sends an
errorevent withstatus: 503andretry_afterfor that utterance; the connection stays open and later utterances are unaffected. - Nothing is logged to usage or billed.
Retry recipe: wait Retry-After + 0–250 ms jitter, retry up to three times, then degrade (cached audio, a shorter line, a text
fallback). For planned load, tell us: capacity is added by adding nodes behind the load balancer, not by queueing on one.
import random, time, requests
def tts(body, tries=3):
for i in range(tries):
r = requests.post(URL, headers=H, json=body, stream=True)
if r.status_code != 503: r.raise_for_status(); return r
time.sleep(float(r.headers.get("retry-after", "1")) + random.random() / 4)
raise RuntimeError("TTS busy")
Timeouts and cancellation¶
- Closing the HTTP connection (or the WebSocket) cancels synthesis within one frame; you are billed only for audio already produced.
- Per-request wall-clock limit: 120 s. A 4 000-character request is ~4 minutes of audio and finishes in well under a minute.
- Keep WebSocket connections open across turns; opening one costs a TLS handshake, not synthesis time.
Health¶
GET /readyz is 200 ready only when every worker has warmed up (else 503 workers warming up); GET /healthz reports uptime and
worker counts; GET /metrics is Prometheus text (vtts_streams_active, vtts_queue_depth, vtts_ttfa_seconds, vtts_rtf,
vtts_audio_seconds_total, vtts_requests_total{endpoint,status}, vtts_errors_total{kind}). Live view: portal status page.