{"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":325,"sourceCode":"      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      // Mirror the HTTP provider: stash the server's per-locale recommendations\n      // so setupOfficialSpeechAutoPick can seed a curated default voice when\n      // the streaming provider becomes active.\n      recommendedVoicesByProvider[OFFICIAL_SPEECH_STREAMING_PROVIDER_ID] = (data.recommended && typeof data.recommended === 'object') ? data.recommended : {}\n\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          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  },\n})\n\nexport const providerOfficialTranscription = defineProvider({","sourceCodeStart":307,"sourceCodeEnd":343,"githubUrl":"https://github.com/moeru-ai/airi/blob/677329427f32468c74b17f3ec47eeca4e05bec65/packages/stage-ui/src/libs/providers/providers/official/index.ts#L307-L343","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":"677329427f32468c74b17f3ec47eeca4e05bec65","analyzedAt":"2026-08-18T17:29:58.153Z","schemaVersion":2},"datasetVersion":"2026-08-23T16:17:53.355Z"}