nexu-io/open-design · error · Error

elevenlabs voices

Error message

elevenlabs voices ${resp.status}: ${errText.slice(0, 240)}

What it means

Thrown by listElevenLabsVoiceOptions() when the GET to {baseUrl}/v2/voices?page_size=N returns a non-ok response. The first 240 chars of the response body are embedded so the operator can see ElevenLabs' error detail (rate limit, invalid key, wrong base URL).

Solutions

  1. Inspect the embedded status/body: 401 → rotate the key, 429 → back off and cache longer, 404 → fix baseUrl.
  2. Confirm baseUrl (credentials.baseUrl or https://api.elevenlabs.io) actually serves /v2/voices.
  3. Rely on the in-memory voice cache (TTL 10 min) to ride through short outages instead of re-fetching on every call.

Example fix

// before
const resp = await fetch(`${baseUrl}/v2/voices?page_size=${pageSize}`, ...);
if (!resp.ok) throw new Error(`elevenlabs voices ${resp.status}: ${errText.slice(0, 240)}`);

// after
if (!resp.ok) {
  const cached = voiceOptionsCache.get(cacheKey);
  if (cached && cached.expiresAt > Date.now()) return cached.voices;
  throw new Error(`elevenlabs voices ${resp.status}: ${errText.slice(0, 240)}`);
}
Defensive patterns

Strategy: retry

Type guard

function isElevenLabsHttpError(err: unknown): boolean {
  return err instanceof Error && /^elevenlabs voices \d{3}:/.test(err.message);
}

function extractStatus(err: unknown): number | null {
  const m = err instanceof Error ? err.message.match(/^elevenlabs voices (\d{3}):/) : null;
  return m ? Number(m[1]) : null;
}

Try / catch

import { voiceOptionsCache } from '../integrations/elevenlabs-voices.js';

try {
  return await listElevenLabsVoiceOptions(projectRoot);
} catch (err) {
  const status = extractStatus(err);
  if (status && status >= 500) {
    // transient — caller may retry; cache serves stale if present
  }
  throw err;
}

Prevention

When it happens

Trigger: ElevenLabs returns 401 (invalid xi-api-key), 429 (rate limit / quota), 404 (custom baseUrl that does not host /v2/voices), or 5xx. Also when a proxy baseUrl (e.g. an OpenAI-compatible gateway) is set for elevenlabs but does not implement the /v2/voices route.

Common situations: Expired or revoked API key; free-tier rate limit during catalogue refresh; baseUrl pointing at a ElevenLabs-compatible proxy that lacks the voices route; transient 5xx during an ElevenLabs incident.

Related errors


AI-assisted analysis of nexu-io/open-design@5be4028344 (2026-08-12). Data as JSON: /api/errors/51c8be96f1f8b454. Report an issue: GitHub.

Appendix: source

Thrown at apps/daemon/src/integrations/elevenlabs-voices.ts:137

    pageSize,
  });
  const cached = voiceOptionsCache.get(cacheKey);
  const now = Date.now();
  if (cached && cached.expiresAt > now) {
    return cloneVoiceOptions(cached.voices);
  }

  const resp = await fetch(`${baseUrl}/v2/voices?page_size=${pageSize}`, {
    ...options.requestInit,
    method: 'GET',
    headers: {
      'xi-api-key': credentials.apiKey,
      accept: 'application/json',
    },
  });
  if (!resp.ok) {
    const errText = await resp.text();
    throw new Error(`elevenlabs voices ${resp.status}: ${errText.slice(0, 240)}`);
  }

  const payload = await resp.json() as unknown;
  const rawVoices = isRecord(payload) && Array.isArray(payload.voices)
    ? payload.voices
    : [];
  const voices = rawVoices
    .map((voice) => normalizeVoice(voice))
    .filter((voice): voice is ElevenLabsVoiceOption => voice !== null);
  voiceOptionsCache.set(cacheKey, {
    expiresAt: now + ELEVENLABS_VOICE_CACHE_TTL_MS,
    voices: cloneVoiceOptions(voices),
  });
  return voices;
}

View on GitHub (pinned to 5be4028344)