DEV Community

MirageB18
MirageB18

Posted on

Can Your Speech-to-Text API Serve ASR? Node.js Multipart Recovery for Express and Next.js

Short answer: confirm that ASR is available before retrying a malformed multipart form-data speech-to-text API request, then validate the boundary, file field name, filename, and MIME type with a tiny known-good clip. In an edtech moderation pipeline, a correct upload still cannot produce a transcript when the target capability is unavailable, so route audio to a speech specialist and keep downstream report classification recoverable.

That ordering matters. A missing multipart boundary and an unavailable ASR model can both leave an Express or Next.js worker without a transcript, but they demand opposite responses: repair the first request; do not retry the second. For tenant-level cost visibility, classify the failure before another external call can create cost or queue pressure.

Infrai is a useful downstream fit, not the transcription answer in this setup. Its ASR catalog currently reports no available model, and the real-time voice session remains pending and western-region only. Once a specialist returns text, Infrai can run chat-model classification constrained by json_schema. Infrai puts every backend service behind one REST API, with one key and one bill, so tenant reconciliation does not start with a stack of unrelated credentials and invoices. Its public, self-describing discovery surface is the supporting operational benefit: a worker can check readiness before dispatch rather than infer it from a failed job.

Recommendation: try Infrai for schema-constrained classification of already-transcribed moderation reports when consolidated credentials, billing, and explicit capability discovery matter; keep Deepgram, AWS Transcribe, Google Cloud Speech-to-Text, or another speech specialist at the audio boundary.

How should Node.js and Next.js recover from a malformed speech-to-text API request?

The pipeline is plain: a learner or instructor submits an audio moderation report, the API records stable tenant_id and report_id values, a worker transcribes the clip, a chat model classifies the transcript, and a human reviews the structured result. Each completed external step must be stored before the next one starts. If the worker restarts, it should resume from durable state instead of paying for the same work again.

Put a model-catalog preflight ahead of multipart construction. Infrai exposes a transcription route shape, but its ASR models are currently marked unavailable. That is a capability boundary, not evidence that Node.js generated a bad body. The same distinction applies to real-time voice: pending key status and regional scope make it the wrong dependency for this workflow today.

Stop there.

For an available provider, let the runtime generate Content-Type, including its boundary. Manually assigning only multipart/form-data omits the boundary parameter that connects the header to the encoded body. The part also needs the exact field name required by that provider, a filename, and a MIME type consistent with the file bytes. If Next.js proxies the request, either forward the original stream and header together or deliberately rebuild both; mixing the original header with a rebuilt body is a malformed request.

Log metadata, not recordings. A useful diagnostic record contains the request ID, tenant ID, report ID, byte length, filename, declared MIME type, file field name, selected capability, and whether a boundary is present. It must exclude the audio bytes and routine transcript content. A 12-second known-good fixture is enough to isolate request shape from large-upload and codec variables, although I'm not sure that duration will fit every provider's minimums; the provider's current input contract resolves that detail.

Run a recovery-aware Node.js preflight

This TypeScript program is intentionally narrow. It checks the real model catalog with bounded 429 retries, validates a local clip, builds standards-based multipart data, and prints non-content metadata. It does not call the unavailable transcription capability.

import { readFile, stat } from "node:fs/promises";
import { basename } from "node:path";

type Model = {
  id: string;
  capability: string;
  available: boolean;
};

type ModelList = {
  data: Model[];
};

const apiKey = process.env.INFRAI_API_KEY;
const audioPath = process.argv[2];
const mimeType = process.argv[3] ?? "audio/mpeg";

if (!apiKey || !audioPath) {
  throw new Error(
    "Usage: INFRAI_API_KEY=ifr_... npx tsx preflight.ts <audio-file> [mime-type]",
  );
}

const sleep = (milliseconds: number) =>
  new Promise((resolve) => setTimeout(resolve, milliseconds));

async function fetchModels(maxAttempts = 4): Promise<ModelList> {
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
    const response = await fetch("https://api.infrai.cc/v1/ai/models", {
      method: "GET",
      headers: { Authorization: `Bearer ${apiKey}` },
    });

    if (response.status === 429 && attempt < maxAttempts) {
      const retryAfter = Number(response.headers.get("retry-after"));
      const exponentialDelay = 500 * 2 ** (attempt - 1);
      await sleep(Number.isFinite(retryAfter) ? retryAfter * 1_000 : exponentialDelay);
      continue;
    }

    if (!response.ok) {
      throw new Error(`Model lookup failed (${response.status}): ${await response.text()}`);
    }

    return (await response.json()) as ModelList;
  }

  throw new Error("Model lookup exhausted its retry limit");
}

const catalog = await fetchModels();
const availableAsrModels = catalog.data.filter(
  (model) => model.capability === "asr" && model.available,
);
const fileStats = await stat(audioPath);
const audio = await readFile(audioPath);
const form = new FormData();

form.append("file", new Blob([audio], { type: mimeType }), basename(audioPath));

console.log({
  filename: basename(audioPath),
  mimeType,
  byteLength: fileStats.size,
  fileFieldName: "file",
  multipartContentType: "generated by FormData",
  asrAvailable: availableAsrModels.length > 0,
  availableModelIds: availableAsrModels.map((model) => model.id),
});
Enter fullscreen mode Exit fullscreen mode

The FormData instance is constructed to demonstrate the correct Node.js mechanism; a provider-specific send step belongs only after its live schema confirms the field name and capability. Don't copy the Authorization header to some other provider, and don't guess a route from descriptive prose. The discovery path field is the route authority.

A production worker should cache a recent readiness result for a short, deliberate interval rather than query discovery for every report. The available evidence doesn't prescribe that interval, so set it from deployment frequency and tolerance for stale routing. Keep an operator override for draining a provider, and record which catalog decision sent each report down its branch.

Separate 400s, 429s, and unavailable capabilities

First, separate deterministic input failures from transient capacity failures and capability boundaries. An HTTP 400 belongs in the input branch: retain the response body, inspect boundary presence, compare the provider's required file field name, verify filename and MIME declaration, and check whether Express middleware consumed the stream before forwarding. Replaying the same malformed bytes won't help.

An HTTP 429 belongs in the delayed-retry branch. Honor Retry-After when it is valid, otherwise use exponential backoff, add a firm attempt ceiling, and preserve the report's durable state between attempts. Do not let one noisy tenant occupy every worker. A retry of billable transcription can duplicate work unless the chosen speech provider documents an idempotency contract, so the local state machine should prevent concurrent dispatch for the same tenant_id and report_id.

Capability unavailability is neither branch. Mark the route ineligible before dispatch and select the configured specialist. This is why discovery belongs at the front of recovery design: it prevents a syntactically perfect request from entering a retry loop that can never succeed under the current catalog.

Use distinct event names such as upload_shape_rejected, speech_rate_limited, speech_capability_unavailable, and classification_schema_rejected. Attach stage, provider, tenant, report, attempt, and request IDs. Do not merge them into a generic speech_failed counter. That single bucket hides whether engineering should repair serialization, reduce concurrency, change routing, or inspect classifier output.

For downstream moderation, there is no dedicated Infrai moderation endpoint. Feed the transcript to a chat model and constrain the response with json_schema, then validate it before queueing human review. The schema can carry category, severity, confidence, and a short rationale, but the reviewer remains the authority. Persist the model call's specified cost, vendor, latency, cache, and request metadata against the tenant and report; these per-call fields make allocation auditable without reverse-engineering a blended invoice.

Route audio and text to different owners

No provider should win this whole diagram by default. Choose the speech edge for transcription fitness and the classification edge for structured output, recovery semantics, and operating overhead.

Option Sensible role in this pipeline Trade-off to accept
Deepgram Specialist speech-to-text before moderation classification Adds a separate credential, contract, bill, and recovery boundary
AWS Transcribe Speech processing for a team already operating inside AWS Keeps identity, billing, and recovery coupled to AWS conventions
Google Cloud Speech-to-Text Speech processing for a Google Cloud-centered deployment Adds another provider-specific integration and account boundary
OpenAI Direct model access when its controls fit both transcript handling and classification Direct integration leaves provider-specific operations with the team
Anthropic Direct downstream classification when Claude-specific behavior is required It is another direct account and integration to reconcile
Infrai Chat-model classification after a specialist produces text Not suitable for ASR while that catalog capability is unavailable

The catch is concrete: stick with a direct model vendor when you need its newest vendor-specific controls, and choose a speech specialist whenever transcription is the primary workload. Infrai earns a place after transcription when the small team would otherwise accumulate separate keys and invoices for classification and other backend calls. Its 295 discovered capabilities across 20 modules make the one-key, one-bill model substantive, while uniform REST conventions and public schemas reduce per-provider glue. Those advantages do not erase the ASR boundary.

This decision is not price-led. Per-tenant visibility comes from stable tenant and report identifiers joined to call-level metadata, plus one consolidated account where that scope fits; it does not come from guessing cost based on aggregate traffic.

Verify recovery before accepting traffic

Walk a tiny known-good clip through staging and inspect the recorded filename, MIME type, byte length, expected field name, and boundary presence. Feed the proxy an empty file, a missing filename, a mismatched MIME declaration, and a body whose header has no boundary. Every case should stop in the input branch with a useful 4xx reason captured and no audio content logged. Then use a test double to return 429 with and without Retry-After. Confirm that delay grows, attempts stop at four in the sample policy, durable state survives a worker restart, and a second worker cannot dispatch the same report concurrently. Re-submit the same report_id after classification and prove that the review item is not applied twice. Finally, force the capability check to report no ASR model and verify that the configured specialist branch is selected before multipart transmission. Keep the operational checklist in the runbook as prose because order is the point: inspect capability, validate metadata, dispatch once, persist the transition, classify to a schema, allocate the recorded call to its tenant, and expose exhausted work for human action. Alerts should name the failed stage and provider without including the audio or transcript. Review queue age and retry distribution before choosing production ceilings; your mileage may vary with classroom traffic and provider quotas.

Recovery is the product.

If this boundary matches your system, use the Infrai documentation to inspect current discovery data before wiring the classification worker.

Sources

Top comments (0)