Your app loads the ElevenLabs voice list successfully. The user picks a voice, clicks Generate, and the text-to-speech request returns 429.
That is a more useful test than an API that always rate-limits. Authentication worked. Discovery worked. The failure happened on the expensive operation, after the UI had already promised progress.
The branch most mocks skip
A generated ElevenLabs integration usually proves this sequence:
GET /v1/voices -> 200
POST /v1/text-to-speech/{voiceId} -> 200
The fixture is green forever. It does not answer:
- What happens when voice discovery succeeds but generation is throttled?
- Does the client retry immediately?
- Does the SDK already retry before your wrapper retries?
- Does the UI create several concurrent generation jobs?
- Is there a maximum attempt count?
- What happens when no
Retry-Afterheader is present?
Those are client-policy questions. Burning production quota until ElevenLabs returns 429 is an expensive way to discover the answers.
A 429 is not permission to retry forever
Rate-limit handling needs a decision function, not a recursive catch:
type RetryDecision =
| { action: "wait"; delayMs: number }
| { action: "stop"; reason: string };
function decide429(
response: Response,
attempt: number,
remainingMs: number
): RetryDecision {
if (attempt >= 3) {
return { action: "stop", reason: "retry budget exhausted" };
}
const retryAfter = response.headers.get("retry-after");
const fallbackMs = Math.min(1000 * 2 ** attempt, 8000);
const delayMs = retryAfter
? parseRetryAfter(retryAfter)
: fallbackMs;
if (delayMs > remainingMs) {
return { action: "stop", reason: "deadline exceeded" };
}
return { action: "wait", delayMs };
}
The important assertions are:
generation_attempts <= 3
voice_list_calls == 1
no duplicate UI jobs
fallback backoff used when Retry-After is absent
controlled error returned when the retry budget ends
Do not assert only that the client eventually threw. A tight loop can throw after hammering the provider twenty times.
Watch for stacked retries
Many SDKs retry transient failures internally. If your application wrapper also retries, the budgets multiply:
SDK attempts: 3
application attempts: 3
actual HTTP requests: 9
That is why the test needs a request count. Pick one layer to own the policy, or coordinate the limits explicitly.
The failure can get worse in a browser. A disabled button that re-enables too early allows the user to start another retry tree while the first is waiting.
Force the real workflow shape without an API key
FetchSandbox ships an ElevenLabs twin with a curated text_to_speech workflow:
-
GET /v1/voicesto discover available voices - Select the returned
voice_id POST /v1/text-to-speech/{voice_id}
Under the rate_limited scenario, only the textToSpeech operation changes:
rate_limited:
overrides:
- operation_id: textToSpeech
response_status: 429
error_code: rate_limit_exceeded
error_detail: "Rate limit exceeded. Please try again later."
The first step still succeeds. The generation step returns the provider-shaped 429. The configured response intentionally has no Retry-After, so this scenario tests your documented fallback backoff and retry ceiling rather than server-directed waiting.
No ElevenLabs API key and no quota consumption are involved.
With FetchSandbox MCP in Cursor or Claude:
Run elevenlabs text_to_speech under rate_limited.
Verify voice discovery succeeds and generation returns 429.
Count every generation attempt, prove the voice list is not fetched
again, and confirm the client stops at its retry budget.
Give me the run receipt.
The receipt provides the request sequence and exact failure. Your app-level test supplies the clock and UI assertions.
Make backoff deterministic
Do not make CI literally sleep through production delays. Inject time:
type RetryClock = {
now(): number;
sleep(ms: number): Promise<void>;
random(): number;
};
Production uses the real clock. Tests use a fake clock and record requested delays:
attempt 1 -> 429 -> requested sleep 1000ms
attempt 2 -> 429 -> requested sleep 2000ms
attempt 3 -> 429 -> stop
You can prove exponential backoff, jitter bounds, cancellation, and the total deadline without adding seconds to every CI run.
Local first, pipeline next
Ask the coding agent to run the twin while it writes the policy. Then keep the same scenario in CI:
fetchsandbox run <sandbox-id> text_to_speech \
--scenario rate_limited \
--json
The local receipt is useful during review. The pipeline prevents a future refactor from changing maxAttempts = 3 into unbounded recursion.
Questions people ask
How can I test ElevenLabs rate limits without using quota?
Point the integration at a service twin and force textToSpeech to return 429. This exercises the client contract without sending audio-generation requests to ElevenLabs.
Should I always use Retry-After?
Honor a valid Retry-After when the provider sends one, subject to your own deadline and attempt cap. When it is absent, use a documented capped backoff with jitter. Test both branches separately.
Should a 429 restart the whole workflow?
No. Resume from the safe failed operation. In this workflow, do not fetch voices again or create a second local generation job merely because text-to-speech was throttled.
Is rate limiting the same as exhausted quota?
No. A temporary rate limit is generally pause-and-retry. A hard quota or billing limit is stop-and-alert. OpenAI, for example, can use HTTP 429 for both while distinguishing them with error codes such as rate_limit_exceeded and insufficient_quota.
Does FetchSandbox replace ElevenLabs testing?
No. Use the twin for repeatable negative paths and CI. Use ElevenLabs afterward for final credentials, model availability, audio quality, and real account limits.
The evidence to attach
Require a receipt showing one successful voice lookup followed by the forced 429, plus an app assertion showing the exact retry count and requested delays.
The canonical guide covers mid-workflow 429 testing without partner quota. Connect the twins through FetchSandbox MCP.
Top comments (0)