{"record":{"id":"6c548184c28cce6c","repo":"moeru-ai/airi","slug":"streaming-voices-upstream-returned-malformed-body","errorCode":null,"errorMessage":"streaming voices upstream returned malformed body","messagePattern":"streaming voices upstream returned malformed body","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/stage-ui/src/libs/providers/providers/official/index.ts","lineNumber":285,"sourceCode":"      if (!res.ok)\n        throw new Error(`streaming voices upstream ${res.status}: ${await res.text().catch(() => '')}`.slice(0, 256))\n\n      const data = await res.json() as {\n        voices?: {\n          id: string\n          name: string\n          description?: string\n          labels?: Record<string, unknown>\n          languages?: { code: string, title: string }[]\n          preview_audio_url?: string\n        }[]\n        recommended?: Record<string, string>\n      }\n\n      // An aborted response must not replace the current session's recommendations.\n      signal?.throwIfAborted()\n      if (!Array.isArray(data.voices))\n        throw new Error('streaming voices upstream returned malformed body')\n\n      return data.voices.map((v) => {\n        const rawGender = typeof v.labels?.gender === 'string' ? (v.labels.gender as string) : undefined\n        return {\n          // Keep recommendations in the response so stale catalogs cannot mutate\n          // a separate cache, and synchronized windows retain the same metadata.\n          recommendedFor: Object.entries(data.recommended ?? {}).filter(([, id]) => id === v.id).map(([locale]) => locale),\n          id: v.id,\n          name: v.name,\n          provider: OFFICIAL_SPEECH_STREAMING_PROVIDER_ID,\n          description: v.description || undefined,\n          gender: rawGender?.toLowerCase() || undefined,\n          previewURL: v.preview_audio_url || undefined,\n          languages: Array.isArray(v.languages) ? v.languages : [],\n        }\n      })\n    },\n  },","sourceCodeStart":267,"sourceCodeEnd":303,"githubUrl":"https://github.com/moeru-ai/airi/blob/438a067dde47aa0bdb46c2323d1fe293dc805218/packages/stage-ui/src/libs/providers/providers/official/index.ts#L267-L303","documentation":"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`.","triggerScenarios":"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.","commonSituations":"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.","solutions":["Inspect the raw body with curl and compare it to the expected `{ voices, recommended }` shape.","Redeploy server and client from the same revision.","Have the server always emit `voices: []` when the catalog is empty rather than omitting the field.","Catch this in the caller and show an empty voice list instead of breaking settings."],"exampleFix":"// before\nif (!Array.isArray(data.voices))\n  throw new Error('streaming voices upstream returned malformed body')\n// after — tolerate an absent catalog while keeping recommendations\nif (!Array.isArray(data.voices)) {\n  console.warn('streaming voices payload lacked voices[]', data)\n  return []\n}","handlingStrategy":"type-guard","validationCode":"const data: unknown = await res.json()\nif (!isStreamingVoicesPayload(data))\n  console.warn('streaming voices payload malformed', data)","typeGuard":"function isStreamingVoicesPayload(data: unknown): data is { voices: Array<{ id: string, name: string }>, recommended?: Record<string, string> } {\n  if (typeof data !== 'object' || data === null)\n    return false\n  return Array.isArray((data as { voices?: unknown }).voices)\n}","tryCatchPattern":"return isStreamingVoicesPayload(data)\n  ? data.voices.map(toVoiceInfo)\n  : []","preventionTips":["Make `voices: []` the server's empty-catalog representation.","Contract-test the streaming voices wire shape.","Log raw payloads on validation failure to catch version skew early.","Keep the recommendation map update idempotent so a malformed body never corrupts defaults."],"tags":["schema","validation","api","server","streaming","voices"],"backgroundTag":"schema-validation-failed","analyzedSha":"438a067dde47aa0bdb46c2323d1fe293dc805218","analyzedAt":"2026-09-17T01:14:42.644Z","contentChangedAt":"2026-09-17T01:14:42.644Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}