Guide · async
Errors, retries and limits
One error envelope everywhere; what to retry (429, 503 unavailable) and how to make a retry safe (Idempotency-Key); credit, plan limits and the 402s.
Every failed call answers with the same JSON envelope, and the SDKs turn it into one error type (VocalStackError) with the same fields. Most errors are final; a few say "try again", and the SDKs already do that for you where it is safe.
{
"defined": true,
"code": "insufficient_credit",
"status": 402,
"message": "this account has no credit left; add credit to continue",
"data": {
"retryable": false,
"details": {
"balance": -0.42,
"required": 0.075,
"currency": "usd",
"plan": "payg",
"topUpUrl": "https://…"
}
},
"requestId": "01J…"
}
Read the error
codeis what to branch on;messageis for people;data.retryablesays whether sending the same request again can succeed;data.detailscarries what the code needs;requestId(also theX-Request-Idheader) is what support asks for. In the SDKs:err.code,err.status,err.message,err.retryable,err.details,err.retryAfter(seconds, fromRetry-After) anderr.headers.Status and code What to do 400, 422 invalid_options,invalid_body,invalid_audioFix the request: the message names the field. 401 invalid_token,key_expired,key_revokedCheck VOCALSTACK_API_KEY; use the replacement key, or create one.402 insufficient_creditThe account's credit is out: details.balance(dollars),plan,topUpUrl, andrequiredwhen the cost was known up front (a file of known length). Add credit; nothing was charged.402 quota_exceededA plan limit is reached: details.metric(concurrent_transcriptions),limit,used,resetsAt(null: it frees up as transcriptions end),plan,topUpUrl.403 forbidden,key_from_browser,suspendedA missing scope (the message names it), a key sent from a web page (keep it on your server), or a suspension. Retrying never helps. 404, 409 not_found,conflict,invalid_stateNo such thing, a clash (a link code taken, another device recording), or not now ( stopon a recording that is not paused; a second subscription checkout while one is active).409 payment_method_requiredAuto top-up ( PUT /v1/billing/auto-top-up) and the customer portal need a saved payment method: make a payment (POST /v1/billing/checkouts) or add a card (POST /v1/billing/payment-methods/manage) first.409 payment_method_in_use,billing_details_required,auto_top_up_offA card that pays the subscription cannot be removed (add another and make it the default first); an invoice needs a billing name and address ( PUT /v1/billing/details); auto top-up is off, so there is nothing to run now.404 promo_code_invalidThe promo code does not exist, has expired, is used up, was redeemed by this account already, or gives nothing for this purchase. 400 range_too_longA usage report covers at most 92 days: ask for a shorter range. 429 rate_limitedWait Retry-Afterseconds. Retried for you. Withdetails.metric: "creates_per_hour"it is the plan's creation rate (new transcriptions an hour), not a burst limit:limitandresetsAtsay when.503 unavailableThe service is waking (its database was asleep). Send it again after Retry-After. Retried for you when it is safe.Retry what can be retried
The SDKs retry a
429 rate_limitedand a503 unavailableafterRetry-After, twice by default (maxRetries/max_retrieson the client), but a503only for a call that is safe to send twice:GET,HEAD,DELETE, or any call carrying anIdempotency-Key. A create without one is not retried, because the first attempt may have worked. Every response carriesRateLimit-Limit,RateLimit-RemainingandRateLimit-Reset: slow down before the 429.GET /v1/billingshows the plan, its rates per hour of audio, the credit balance and the limits (transcriptions at once, new transcriptions an hour, requests a minute, the largest file, the longest live recording).Make creates safe to retry
Send
Idempotency-Key: <a fresh UUID>with a create (POST /v1/broadcasts,POST /v1/transcriptions,POST /v1/transcriptions/live, starting a recording…). Repeated within 24 hours with the same key and the same body, it returns the first answer (withIdempotent-Replayed: true) instead of creating a second thing; the same key with a different body is422 idempotency_key_reused, and a repeat while the first is still running is409 conflict. Keys are per account. In the SDKs:{ idempotencyKey }as the call options (TypeScript),idempotency_key=(Python).Sockets
A refused WebSocket upgrade carries the same envelope over HTTP:
401 invalid_token,410 session_expired(a realtime session'sexpiresAtpassed: create a new one),503 unavailable(retry afterRetry-After). A fleet with every machine busy can instead hang the connect or answer a bare 503 with no envelope: the SDKs report that ascapacity_exceeded, and asengine_unavailablewhen the machine started warming up and then went quiet. On an open socket, anerrorframe carriescode,messageandretryable;slow_consumermeans you read too slowly and were closed (commits are never dropped, drafts are). There is no resume: create a new session.
TypeScript SDK
import { VocalStack, VocalStackError } from '@vocalstack/sdk';
// 429s, and 503s on calls that are safe to repeat, are retried after Retry-After.
const vs = new VocalStack({ baseUrl: 'https://api.vocalstack.com', apiKey: process.env.VOCALSTACK_API_KEY, maxRetries: 4 });
try {
// The key makes this create safe to retry: a repeat returns the first broadcast.
const broadcast = await vs.broadcasts.create({ name: 'Town hall' }, { idempotencyKey: crypto.randomUUID() });
console.log(broadcast.shareUrl);
} catch (err) {
if (!(err instanceof VocalStackError)) throw err;
switch (err.code) {
case 'insufficient_credit':
case 'quota_exceeded': {
const d = err.details as { metric?: string; balance?: number; required?: number; topUpUrl?: string } | undefined;
console.error(err.message, d?.metric ?? '', d?.balance ?? '', d?.required ?? '', d?.topUpUrl ?? '');
break;
}
case 'rate_limited':
case 'unavailable':
console.error(`still ${err.code} after the retries; try again in ${err.retryAfter ?? 1} s`);
break;
default:
console.error(err.status, err.code, err.message, err.headers?.get('x-request-id') ?? '');
}
}
const billing = await vs.billing.get();
console.log(billing.plan?.name, `${billing.balance} of credit`, `${billing.limits?.concurrentTranscriptions ?? '∞'} at once`);
Python SDK
import asyncio
import os
import uuid
from vocalstack import VocalStack, VocalStackError
async def main() -> None:
# 429s, and 503s on calls that are safe to repeat, are retried after Retry-After.
async with VocalStack("https://api.vocalstack.com", api_key=os.environ["VOCALSTACK_API_KEY"], max_retries=4) as vs:
try:
# The key makes this create safe to retry: a repeat returns the first broadcast.
broadcast = await vs.broadcasts.create(name="Town hall", idempotency_key=str(uuid.uuid4()))
print(broadcast.share_url)
except VocalStackError as e:
if e.code in ("insufficient_credit", "quota_exceeded"):
print(e.message, e.details)
elif e.code in ("rate_limited", "unavailable"):
print(f"still {e.code} after the retries; try again in {e.retry_after or 1} s")
else:
print(e.status, e.code, e.message)
billing = await vs.billing.get()
print(billing.plan, f"${billing.balance} of credit", billing.limits)
asyncio.run(main())
HTTP + WebSocket
#!/usr/bin/env bash
# curl and jq, no SDK: a create with an Idempotency-Key, retried on 429 and 503.
set -euo pipefail
API=https://api.vocalstack.com
AUTH="Authorization: Bearer $VOCALSTACK_API_KEY"
KEY=$(uuidgen) # the same key on every attempt: a repeat returns the first answer
for attempt in 1 2 3 4 5; do
HEADERS=$(mktemp)
STATUS=$(curl -sS -o body.json -D "$HEADERS" -w '%{http_code}' "$API/v1/broadcasts" \
-H "$AUTH" -H "Content-Type: application/json" -H "Idempotency-Key: $KEY" \
-d '{"name":"Town hall"}')
case $STATUS in
2??) jq -r .shareUrl body.json; exit 0 ;;
429|503)
WAIT=$(awk 'tolower($1)=="retry-after:"{print $2+0}' "$HEADERS")
echo "$STATUS $(jq -r .code body.json): retrying in ${WAIT:-2} s" >&2
sleep "${WAIT:-2}" ;;
*)
jq -r '"\(.status) \(.code): \(.message) (request \(.requestId))"' body.json >&2
jq '.data.details' body.json >&2
exit 1 ;;
esac
done
exit 1