moeru-ai/airi · error · Error

streaming voices upstream returned malformed body

Error message

streaming voices upstream returned malformed body

What it means

Thrown by the official streaming speech provider's `listVoices()` when `/api/v1/audio/voices/streaming` answered 200 but `data.voices` is not an array. Before the check the handler stores `data.recommended` into the per-provider recommendation map, so a malformed body still updates recommendations and then fails the catalog mapping. Same wire family as unspeech `ListVoicesResponse`.

Solutions

  1. Inspect the raw body with curl and compare it to the expected `{ voices, recommended }` shape.
  2. Redeploy server and client from the same revision.
  3. Have the server always emit `voices: []` when the catalog is empty rather than omitting the field.
  4. Catch this in the caller and show an empty voice list instead of breaking settings.

Example fix

// before
if (!Array.isArray(data.voices))
  throw new Error('streaming voices upstream returned malformed body')
// after — tolerate an absent catalog while keeping recommendations
if (!Array.isArray(data.voices)) {
  console.warn('streaming voices payload lacked voices[]', data)
  return []
}
Defensive patterns

Strategy: type-guard

Validate before calling

const data: unknown = await res.json()
if (!isStreamingVoicesPayload(data))
  console.warn('streaming voices payload malformed', data)

Type guard

function isStreamingVoicesPayload(data: unknown): data is { voices: Array<{ id: string, name: string }>, recommended?: Record<string, string> } {
  if (typeof data !== 'object' || data === null)
    return false
  return Array.isArray((data as { voices?: unknown }).voices)
}

Try / catch

return isStreamingVoicesPayload(data)
  ? data.voices.map(toVoiceInfo)
  : []

Prevention

When it happens

Trigger: 200 response with an error envelope or `null`; version skew where the streaming voices route returns a different shape than `{ voices: [...], recommended?: {...} }`; an edge returning 200 HTML that `res.json()` happens not to choke on (rare) or a JSON body without the key.

Common situations: Rolling deploy mixing old server and new client; streaming voice endpoint still experimental on the server and returning an empty object when the upstream has no voice catalog; proxy rewriting failures to 200.

Understand the failure class

Background: Schema validation failed / invalid input schema: payload rejected because its shape doesn't match the expected schema — this error's family across 28 libraries.

Related errors


AI-assisted analysis of moeru-ai/airi@438a067dde (2026-09-17). Data as JSON: /api/errors/6c548184c28cce6c. Report an issue: GitHub.

Appendix: source

Thrown at packages/stage-ui/src/libs/providers/providers/official/index.ts:285

      if (!res.ok)
        throw new Error(`streaming voices upstream ${res.status}: ${await res.text().catch(() => '')}`.slice(0, 256))

      const data = await res.json() as {
        voices?: {
          id: string
          name: string
          description?: string
          labels?: Record<string, unknown>
          languages?: { code: string, title: string }[]
          preview_audio_url?: string
        }[]
        recommended?: Record<string, string>
      }

      // An aborted response must not replace the current session's recommendations.
      signal?.throwIfAborted()
      if (!Array.isArray(data.voices))
        throw new Error('streaming voices upstream returned malformed body')

      return data.voices.map((v) => {
        const rawGender = typeof v.labels?.gender === 'string' ? (v.labels.gender as string) : undefined
        return {
          // Keep recommendations in the response so stale catalogs cannot mutate
          // a separate cache, and synchronized windows retain the same metadata.
          recommendedFor: Object.entries(data.recommended ?? {}).filter(([, id]) => id === v.id).map(([locale]) => locale),
          id: v.id,
          name: v.name,
          provider: OFFICIAL_SPEECH_STREAMING_PROVIDER_ID,
          description: v.description || undefined,
          gender: rawGender?.toLowerCase() || undefined,
          previewURL: v.preview_audio_url || undefined,
          languages: Array.isArray(v.languages) ? v.languages : [],
        }
      })
    },
  },

View on GitHub (pinned to 438a067dde)