Errors and rate limits
Classify failure outcomes, preserve structured evidence, and retry only bounded safe operations.
On any failure, first preserve the signal, stop the affected work, and decide whether you know the outcome. Retry only when the operation is safe to repeat, the structured state permits it, and an app deadline remains.
1. Classify where the failure surfaced
| Surface | What you observe | First action |
|---|---|---|
| Transport | Connect, read, inactivity, or disconnect failure with no platform response | Treat the outcome as unknown until the operation contract proves it safe to repeat; stop and close the affected transport |
| HTTP | Non-2xx status and usually a structured ErrorResponse body | Preserve status and body, then branch on code and retryable |
| Socket.IO | Structured error event with code, message, retryable, timestamp, and optional id | Stop feeding the routed request or stream before deciding on recovery |
| SDK | Typed batch exception, structured Socket.IO callback, or a generic rejection after projection | Use the richest typed or callback signal available; don't branch on exception text |
A missing response isn't the same as retryable: true. A structured
retryable: true is also not sufficient by itself: uploads and interrupted
streams can remain unsafe or ambiguous to repeat.
2. Preserve HTTP and SDK fields
Protected Batch REST and realtime HTTP operations use the HTTP
ErrorResponse shape:
| Field | Meaning |
|---|---|
error | Legacy identifier; keep for diagnostics and compatibility |
code | Machine-readable category for app branching |
detail | Optional human-readable detail; don't branch on its wording |
message | Legacy optional message on some authentication responses |
job_id | Optional Batch job or Realtime HTTP stream UUID tied to the failure |
request_id | Optional Batch request-correlation identifier |
retryable | Whether the server classifies this request failure as retryable |
timestamp | Server timestamp |
data | Optional structured limit or validation evidence; preserve its fields and units |
This validated batch example means “fix the multipart body and don't retry it unchanged”:
{
"error": "error.api.error.multipart.file.missing",
"code": "VALIDATION_REQUIRED_FIELD",
"detail": "error.api.error.multipart.file.missing",
"retryable": false,
"timestamp": "2026-01-15T10:30:00Z"
}Always check the HTTP status before selecting a success parser. Preserve a raw body when structured parsing fails.
SDK 0.18.0 exposes different projections:
| SDK surface | Structured information |
|---|---|
| JavaScript batch | BatchTranscribeError exposes statusCode, payload, code, retryable, jobId, detail, timestamp, capacity, and rawBody; rate-limit errors add retryAfter |
| Python batch | BatchTranscribeError exposes status_code, payload, code, retryable, job_id, detail, timestamp, capacity, and raw_body; rate-limit errors add retry_after |
| Socket.IO callbacks | SDK ErrorResponse preserves optional id, message, code, retryable, timestamp, retry_after_seconds, data, reason, and retry_scope |
| TTS server-error rejection | JavaScript rejects with generic Error; Python raises generic RuntimeError; the rejection keeps the message only, so record structured fields in onError / on_error |
| Workload-limit codes | SDK 0.18.0 preserves data, classifies the current limit codes, and routes an id-bearing rejection to the active request context. The global callback still fires first; the synthesis call then rejects with its generic message-only error. |
| TTS content-policy codes | SDK 0.18.0 exports TTS_INPUT_NOT_ALLOWED and TTS_MODERATION_UNAVAILABLE, classifies both as TTS-owned, and routes an id-bearing error to the active synthesis context |
Still pre-validate TTS input before sending and always pass an explicit deadline
(timeoutSeconds in JavaScript, timeout_seconds in Python). Read code and
data from the global error callback before the request rejects; the generic
exception keeps only the message.
3. Apply the decision table
| Observed state | Outcome interpretation | Action |
|---|---|---|
400 or validation code | Request was invalid | Correct UUID, parameters, framing, file, or audio; don't retry unchanged |
401 or authentication code | Key is missing or invalid | Fix credentials before another request |
403 with AUTH_FORBIDDEN | The platform accepted the key but it lacks the capability | Update access through your organization's key-management flow; don't retry unchanged |
Other 403 response | A gateway or intermediary rejected the request | Preserve the raw body or support identifier, then verify URL, route, credentials, and any deployment-specific header requirements |
404 / TRANSCRIPTION_JOB_NOT_FOUND | Requested job isn't available under that UUID | Stop polling that UUID and investigate the stored identifier |
405 / METHOD_NOT_ALLOWED | Path or method is wrong | Correct routing before another request |
Batch 422 with a validation code such as VALIDATION_FILE_CORRUPT | File is unsupported, corrupt, empty, or zero-duration | Correct the input; don't retry the same bytes |
Batch 422 / AUDIO_DURATION_EXCEEDED with data.bound audio_duration | The audio is valid but decodes to longer than the accepted duration | Split the recording to data.limit seconds or less, or submit a shorter file; don't retry unchanged |
Batch 422 / FILE_COUNT_EXCEEDED | Too many parts for one request. data.bound says which, and data.unit counts it: file_parts counts audio files, multipart_parts counts every multipart part | For file_parts, send at most data.limit audio file per request; for multipart_parts, keep the whole form within data.limit parts; don't retry unchanged |
Realtime TTS 422 / CHARACTER_COUNT_EXCEEDED | Text, or a voice reference's transcript, is longer than its ceiling. data.bound says which: tts_input_characters or tts_voice_reference_text_characters | Shorten the text using data.limit; don't retry unchanged |
Realtime TTS 422 / VOICE_REFERENCE_COUNT_EXCEEDED | More than one entry in voice_references; exactly one is accepted | Send a single reference; don't retry unchanged |
Realtime TTS 422 / AUDIO_DURATION_EXCEEDED with data.bound tts_voice_reference_duration | The reference clip is longer than the deployment's configured reference ceiling, which a deployment may set below the model's own limit | Trim the clip to data.limit seconds — data.limit is authoritative, not any published default; don't retry unchanged |
Realtime TTS 413 / PAYLOAD_TOO_LARGE with data.bound tts_voice_reference_bytes | The reference clip's decoded size is over the ceiling | Send a shorter or lower-sample-rate clip; don't retry unchanged |
TTS 400 / TTS_INPUT_NOT_ALLOWED | The content policy rejected the text; the same text will not be accepted | Change the text before sending another request; don't retry it unchanged |
TTS 503 / TTS_MODERATION_UNAVAILABLE | The moderation authority could not make a decision, so synthesis failed closed | Do not treat this as a policy rejection. Retry only after bounded backoff and while the application deadline remains |
402 / CREDITS_EXHAUSTED | Funding is exhausted; an immediate replay cannot restore it | Stop the affected work and don't retry unchanged. A mid-stream realtime event is terminal and is followed by disconnect. |
503 / BILLING_AUTHORIZATION_UNAVAILABLE | The billing authority could not make a decision, so the platform failed closed | Stop the affected work and retry only after bounded backoff. A mid-stream realtime event is terminal and requires a new stream. |
Realtime 409 / ASR_STREAM_EXPIRED | Audio inactivity or a lost backend sequence retired this ASR stream | Read reason, require retry_scope: "new_stream", preserve committed output, and start a fresh UUID; never replay on the retired id. |
Batch 429 / RATE_LIMIT_EXCEEDED | Explicit capacity backpressure | Queue or slow submissions, then use a bounded retry policy |
HTTP 5xx with retryable: true on a Batch result GET made with save_result=true | Preserved result read failed | Retry with capped backoff and jitter while the deadline remains |
HTTP 5xx with retryable: true on an upload | Server invites retry, but creation outcome can still be ambiguous | Don't blindly replay; apply an explicit duplicate-risk policy |
Any response with retryable: false | Server says not to retry this request state | Stop until input, credentials, route, or configuration changes |
| Read timeout before any response | No platform classification | Retry only a read whose contract preserves the result, and only within the deadline |
| Realtime error or disconnect | Old stream state and accepted-audio boundary can be uncertain | Stop, preserve committed results, clean up, and recover with a fresh UUID if policy permits |
TTS voice_id resolution error (SAU-2258) | A caller-supplied voice_id that is not a valid UUID is 400 VALIDATION_INVALID_UUID; a well-formed one that does not identify an available voice is 400 TTS_VOICE_NOT_FOUND (both non-retryable). A resolved voice with incomplete or corrupt stored data is a non-retryable 500 TTS_VOICE_RESOLUTION_FAILED; a transient database/storage outage during resolution is a retryable 503 SERVER_DEPENDENCY_FAILURE. Genuine model/capacity/inference failures stay a retryable 500 TTS_SYNTHESIS_FAILED | Fix the voice_id or send voice_references; retry only the 503 and 500 TTS_SYNTHESIS_FAILED with backoff, never the two non-retryable 400s or 500 TTS_VOICE_RESOLUTION_FAILED |
TTS 400 after supplying both voice selectors, or a malformed reference | Supplying voice_id together with a non-empty voice_references, an explicit empty voice_references array, or reference audio that is not strictly canonical base64 mono PCM16 RIFF/WAVE, is a non-retryable validation error | For SDK calls, choose exactly one of voice_id or one canonical voice_references entry. Direct HTTP also permits neither selector, which uses deployment-defined voice selection. Never send both. |
A deployment gateway can return 429 for a Realtime HTTP request, while the
current Fast/live backend can collapse an internal audio-capacity failure into
retryable 500 with ASR_TRANSCRIPTION_FAILED. Do not infer a realtime quota,
capacity pool, or reset window from either response or from Batch capacity.
4. Treat Batch 429 as capacity backpressure
Batch 429 includes the normal structured fields plus data.capacity:
{
"error": "error.rate_limit",
"code": "RATE_LIMIT_EXCEEDED",
"detail": "error.rate_limit",
"retryable": true,
"timestamp": "2026-01-15T10:30:00Z",
"data": { "capacity": 120.5 }
}capacity is remaining capacity measured in audio seconds. Use it for queue
and admission decisions. It is not a documented quota, reset window, or number
of seconds to sleep.
The OpenAPI contract doesn't promise a Retry-After header. SDK
retryAfter / retry_after is populated only when a valid header is actually
present. If it is absent, apply capped exponential backoff with jitter. Always
bound attempts, individual delays, total retry time, and producer concurrency
with an overall deadline.
5. Preserve a Batch result before retrying its read
The released SDK doesn't retry batch calls. maxRetries / max_retries is
deprecated, ignored, and retained only for compatibility. These tested fixtures
retry getResult() / get_result(), not an upload, and explicitly pass
saveResult: true / save_result=True. The default false can clear a
terminal result after building the response, so a lost response can be followed
by cleared. The preservation option makes bounded retry possible but does not
define a retention duration. The fixtures use the released typed properties:
import {
BatchTranscribeClient,
BatchTranscribeError,
BatchTranscribeRateLimitError,
Language,
type TranscriptionResponse,
} from '@humain-voice/sdk';
function requiredEnv(name: 'API_KEY' | 'API_URL'): string {
const value = process.env[name];
if (!value) throw new Error(`${name} is required`);
return value;
}
async function pause(milliseconds: number): Promise<void> {
await new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
}
async function getResultWithRetry(
client: BatchTranscribeClient,
jobId: string,
attempts = 5,
): Promise<TranscriptionResponse> {
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
// saveResult prevents a terminal read from clearing the stored result
// before a retry. It does not establish a retention duration.
return await client.getResult(jobId, Language.ArEn, { saveResult: true });
} catch (error: unknown) {
if (!(error instanceof BatchTranscribeError)) throw error;
const rateLimited = error instanceof BatchTranscribeRateLimitError;
const retryable = rateLimited || error.retryable === true;
console.error({
statusCode: error.statusCode,
code: error.code,
capacity: error.capacity,
});
if (!retryable || attempt === attempts) throw error;
const serverDelayMs = rateLimited && error.retryAfter !== undefined
? error.retryAfter * 1_000
: 0;
const exponentialDelayMs = 500 * 2 ** (attempt - 1);
await pause(Math.max(serverDelayMs, exponentialDelayMs) + Math.random() * 250);
}
}
throw new Error('Retry loop exhausted');
}
async function main(): Promise<void> {
const jobId = process.argv[2];
if (!jobId) throw new Error('Pass a batch job ID as the first argument');
const client = new BatchTranscribeClient({
api_url: requiredEnv('API_URL'),
api_key: requiredEnv('API_KEY'),
});
try {
const result = await getResultWithRetry(client, jobId);
console.info(result.status, result.results?.transcript ?? '');
} finally {
await client.close();
}
}
void main().catch((error: unknown) => {
console.error(error);
process.exitCode = 1;
});
Expected behavior: retryable reads use at most five attempts, respect an actual
retryAfter / retry_after value when present, add exponential delay and
jitter, and rethrow the final failure. The attempt bound doesn't replace an
overall app deadline.
6. Keep ambiguous operations out of automatic retry
Complete-file uploads
Batch job creation and fast complete-audio submission don't document an idempotency-key contract. A timeout or disconnect after bytes were sent doesn't prove either success or failure: the server may have accepted the request.
If a jobId or request UUID was returned, retain it and continue through that
operation's normal result path. Without a conclusive response, record the
ambiguous attempt and repeat only under an app policy that explicitly accepts
duplicate work.
Realtime streams
The public Socket.IO and HTTP contracts define neither transparent resume nor idempotent frame replay. On an error or disconnect:
- Stop sending audio and close the old stream or response.
- Preserve committed results and discard unresolved provisional state.
- Record the uncertain audio interval.
- Reconnect with bounded backoff and a fresh UUID only if the error and app deadline permit it.
- Keep the new stream separate until the app explicitly reconciles timelines.
Don't replay old frames or reuse the old UUID under an assumption of server-side deduplication.
7. Separate timeouts from the overall deadline
| Control | What it bounds | What expiry proves |
|---|---|---|
| Connect or request timeout | One network phase or request | The client stopped waiting; not whether the server acted |
| Read or inactivity timeout | Time waiting for the next byte, chunk, or event | No progress arrived in that interval; not total operation duration |
| Realtime final-wait timeout | Wait after the final input frame | The wait ended; not that a final result arrived |
| Overall app deadline | Connection, work, retries, delays, and cleanup together | The app must stop further work |
JavaScript TTS timeoutSeconds defaults to 30 seconds of inactivity while
waiting for each audio chunk. Python TTS has no timeout unless
timeout_seconds is supplied. Realtime ASR SDK close() waits only for
protocol-level is_final, a routed error, or its close timeout and can return when
that wait expires. None of these controls replaces the overall deadline.
After any expiry, clean up in finally: cancel HTTP reads and close response
bodies; stop Socket.IO audio and call disconnect() or exit the Python async
context; close the batch client. Don't infer that client cleanup canceled
server-side work or in-flight TTS synthesis.
8. Log decisions and test the failure paths
Log the operation, transport, HTTP status or event name, code, retry decision,
request or job UUID, attempt, deadline remaining, timeout type, and Batch
capacity when present. Don't log API keys, full audio, or sensitive transcript
text by default.
Next, run the safe-read fixture, then test invalid input, authentication,
explicit 429, read timeout, ambiguous upload timeout, realtime disconnect,
final-wait expiry, and cleanup in an isolated test environment.