TTS API rate limits: 60 vs 120 RPM and Retry-After — Audra

Batch workloads hit rate limits only two ways: startup thundering-herd (fifty parallel renders at once) and polling that hammers a status endpoint. Audra's limits are generous for batch — 60 requests/minute free, 120 commercial — but the shape of your requests matters more than the number.

This post is the numbers, the Retry-After contract, and the request-shaping strategy for long-form pipelines.

The actual limits

One nuance: a request is a request, not a character count. A 50k-character sync render is one request against the minute budget — which is why fewer, larger requests win, the opposite of realtime API instincts.

When you hit 429

The response carries a Retry-After header in seconds. Respect it literally: sleep that long on the key that was limited, then retry the same request. Do not compute your own backoff — the server tells you when it will accept traffic again, and guessing just extends the throttling. Repeatedly ignoring Retry-After is the only way to turn a 429 into a longer block.

Request shaping for long-form

curl
# Long-form pipeline: few large requests, sequential
for section in sections:
    curl https://audratalks.com/v2/speech \
      -H "Authorization: Bearer sk-audra-..." \
      -H "Content-Type: application/json" \
      -d "{\"model\":\"audra-essential\",\"text\":\"$(cat $section)\",\"voice\":\"june\"}" \
      --output "$section.mp3"
    sleep 1.1
Python
import requests, time

# One request per chapter, up to 50k chars each - sequential, 1/sec
for chapter in chapters:
    resp = requests.post(
        "https://audratalks.com/v2/speech",
        headers={"Authorization": "Bearer sk-audra-..."},
        json={"model": "audra-essential", "voice": "june", "text": chapter, "format": "mp3"},
        timeout=3600,
    )
    if resp.status_code == 429:
        wait = int(resp.headers.get("Retry-After", "30"))
        time.sleep(wait)
        continue  # re-enqueue this chapter
    resp.raise_for_status()
JavaScript
// Few large requests beat many small ones; space them out.
for (const chapter of chapters) {
  const res = await fetch("https://audratalks.com/v2/speech", {
    method: "POST",
    headers: { Authorization: "Bearer sk-audra-...", "Content-Type": "application/json" },
    body: JSON.stringify({ model: "audra-essential", voice: "june", text: chapter, format: "mp3" }),
  });
  if (res.status === 429) {
    const wait = Number(res.headers.get("Retry-After") ?? 30) * 1000;
    await new Promise((r) => setTimeout(r, wait));
    continue; // re-enqueue this chapter
  }
  if (!res.ok) throw new Error(await res.text());
  await writeFile(out, Buffer.from(await res.arrayBuffer()));
}

Why fewer, larger requests win

Splitting a 45k-character script into 100 requests of 450 chars to 'parallelize' produces exactly nothing faster — each render still takes its synthesis time — while burning 100 of your 60-per-minute budget and creating 99 chunk joins that can glitch. Send the 45k as one request; the render time is a function of characters, not request count. Parallelism in batch narration has no payoff.

The auto-queue interaction

Sync requests above roughly 8,000 characters are auto-converted to background jobs unless you send X-Audra-Sync: true. So in practice, large batch requests leave the 429 pressure zone entirely — job creation is a fast enqueue, and the long renders happen off the synchronous path. The async jobs post covers that lifecycle.

Failure mode: the polling loop

The most common self-inflicted 429 in practice is not render volume — it is a poll-every-500ms status loop on /v2/speech/jobs/{id}. Poll every 30–60 seconds, or better, use webhook_url and stop polling altogether.

Error code reference including 413 on oversized sync text: /docs#errors.