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…"
}
  1. Read the error

    code is what to branch on; message is for people; data.retryable says whether sending the same request again can succeed; data.details carries what the code needs; requestId (also the X-Request-Id header) is what support asks for. In the SDKs: err.code, err.status, err.message, err.retryable, err.details, err.retryAfter (seconds, from Retry-After) and err.headers.

    Status and code What to do
    400, 422 invalid_options, invalid_body, invalid_audio Fix the request: the message names the field.
    401 invalid_token, key_expired, key_revoked Check VOCALSTACK_API_KEY; use the replacement key, or create one.
    402 insufficient_credit The account's credit is out: details.balance (dollars), plan, topUpUrl, and required when the cost was known up front (a file of known length). Add credit; nothing was charged.
    402 quota_exceeded A 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, suspended A 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_state No such thing, a clash (a link code taken, another device recording), or not now (stop on a recording that is not paused; a second subscription checkout while one is active).
    409 payment_method_required Auto 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_off A 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_invalid The 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_long A usage report covers at most 92 days: ask for a shorter range.
    429 rate_limited Wait Retry-After seconds. Retried for you. With details.metric: "creates_per_hour" it is the plan's creation rate (new transcriptions an hour), not a burst limit: limit and resetsAt say when.
    503 unavailable The service is waking (its database was asleep). Send it again after Retry-After. Retried for you when it is safe.
  2. Retry what can be retried

    The SDKs retry a 429 rate_limited and a 503 unavailable after Retry-After, twice by default (maxRetries / max_retries on the client), but a 503 only for a call that is safe to send twice: GET, HEAD, DELETE, or any call carrying an Idempotency-Key. A create without one is not retried, because the first attempt may have worked. Every response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset: slow down before the 429. GET /v1/billing shows 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).

  3. 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 (with Idempotent-Replayed: true) instead of creating a second thing; the same key with a different body is 422 idempotency_key_reused, and a repeat while the first is still running is 409 conflict. Keys are per account. In the SDKs: { idempotencyKey } as the call options (TypeScript), idempotency_key= (Python).

  4. Sockets

    A refused WebSocket upgrade carries the same envelope over HTTP: 401 invalid_token, 410 session_expired (a realtime session's expiresAt passed: create a new one), 503 unavailable (retry after Retry-After). A fleet with every machine busy can instead hang the connect or answer a bare 503 with no envelope: the SDKs report that as capacity_exceeded, and as engine_unavailable when the machine started warming up and then went quiet. On an open socket, an error frame carries code, message and retryable; slow_consumer means you read too slowly and were closed (commits are never dropped, drafts are). There is no resume: create a new session.

TypeScript SDK

errors.ts

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

errors.py

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

errors.sh

#!/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