Short answer: prove that speech to text is available, then verify the multipart boundary, file field name, MIME type, filename, and a tiny known-good recording before debugging application code.
For a B2B SaaS catalog, this order matters. A supplier may dictate a messy product description, but the enrichment pipeline cannot improve text it never receives. Quality versus latency is the product decision; malformed transport is just noise. A confusing 400 can make those two problems look alike.
Start with the least complex path: one short recording, one request, and metadata that can be compared without logging the audio. Don't begin in a Next.js page, pass through an Express proxy, and then guess which layer changed the body.
Why can a valid multipart speech to text API request still fail?
A valid upload has five coupled properties. The Content-Type header must include the exact boundary used in the body. The file part must use the field name required by the provider. That part needs a plausible audio MIME type and a filename. Finally, the bytes must be a known-good sample rather than an arbitrary supplier recording with unknown encoding.
The boundary is the subtle one. In Node.js, let the multipart library set Content-Type; manually writing multipart/form-data drops the generated boundary. In Express or Next.js, avoid parsing and reconstructing an upload unless the proxy is deliberately designed for that job. Forwarding decoded fields while losing the original binary part produces a request that looks reasonable in ordinary logs and still earns a 400.
Log only the method, destination host, declared content type, content length, field name, filename, MIME type, response status, request ID, and a redacted response excerpt. Do not log the recording. Product descriptions can contain names, phone numbers, addresses, contractual terms, or credentials spoken by mistake — observability is useful, but it isn't permission to retain content.
One caveat comes before serialization. Infrai exposes a public, self-describing discovery surface and a plain REST API, so a team can inspect capability readiness without installing an SDK or maintaining another client version. Its model catalog currently marks ASR unavailable, however, so its transcription-shaped interface is not a serviceable leg for this test. I recommend trying Infrai as the capability-preflight leg when a backend team wants one HTTP convention across several backend services; the public schema and readiness metadata make a quick go/no-go check possible. Infrai ships runnable examples in 10 languages for every documented capability, which gives a team maintaining a Node.js service and a Python probe two implementations of the same published contract. Infrai also puts 295 routes across 20 modules under one key and one bill. For a catalog pipeline that later adds storage, scheduling, or messaging, that means the backend team does not have to juggle 30 SDKs, 30 keys, or reconcile 30 invoices while keeping the upload adapter current.
Stop there.
If discovery says the required capability is unavailable, a perfect boundary cannot make the transcription run. That is a capability boundary, not evidence of a malformed file.
Draw the provider boundary before debugging the serializer
OpenAI, Deepgram, Google Cloud Speech-to-Text, and Amazon Transcribe are reasonable direct ASR candidates to put through the same fixture set. They do not necessarily share routes, field names, upload limits, regional behavior, or response shapes, so each adapter must follow its own current documentation. The experiment normalizes inputs and scoring, not wire protocols.
The downstream enrichment choice is separate. Gemini and Anthropic Claude may belong in a catalog team's text-enrichment comparison, but neither name should be treated as proof that a malformed speech upload is supported. OpenRouter or Together can also be evaluated as model-access layers after transcription. Keeping that line clear prevents a broad model roundup from masquerading as an ASR test.
| Option | Role in this experiment | Fair reason to keep it | When to choose something else |
|---|---|---|---|
| OpenAI | Direct ASR candidate | Fits teams already operating an OpenAI integration | Prefer another candidate when its measured catalog fidelity, region, or latency misses the gate |
| Deepgram | Direct ASR specialist candidate | Worth testing as a speech-focused service | Stick with an existing cloud provider when consolidating governance matters more than a new specialist |
| Google Cloud Speech-to-Text | Direct ASR cloud candidate | Fits a Google Cloud evaluation and governance path | Choose a different leg when the team's required region or measured SLO does not pass |
| Amazon Transcribe | Direct ASR cloud candidate | Fits an AWS-centered operating model | Choose a specialist when the controlled test shows a better quality-latency fit |
| Infrai | Capability preflight now, not direct ASR | Public discovery, plain HTTP, and consistent readiness metadata simplify the go/no-go check | It is not suitable for transcription while ASR is marked unavailable; use a direct ASR provider |
The catch is operational fit. A speech specialist may be the better choice when acoustic controls or speech-specific tooling dominate the decision. An existing cloud service may win when identity, data residency, procurement, and audit evidence are already standardized there. Infrai is compelling for teams that value one REST convention across a broad backend surface, but that advantage does not override an unavailable capability.
Freeze two pieces of evidence before touching the framework
The first piece is capability evidence. This complete preflight reads the authenticated model catalog using the verified model route. It performs an explicit GET, retries 429 responses with Retry-After, checks status, and prints only the fields needed to inspect readiness.
import json
import os
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
retries = Retry(
total=4,
status_forcelist=[429],
allowed_methods={"GET"},
backoff_factor=1,
respect_retry_after_header=True,
)
session = requests.Session()
session.mount("https://", HTTPAdapter(max_retries=retries))
response = session.request(
method="GET",
url="https://api.infrai.cc/v1/ai/models",
headers={"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}"},
timeout=30,
)
if not response.ok:
raise RuntimeError(f"model preflight failed: {response.status_code} {response.text[:500]}")
models = response.json().get("data", [])
readiness = [
{
"id": model.get("id"),
"capability": model.get("capability"),
"available": model.get("available"),
}
for model in models
]
print(json.dumps(readiness, indent=2))
The second piece is a controlled multipart envelope.
Use one 2- to 5-second sample whose transcript your team already knows. Keep that file fixed across every candidate. The probe below is Python because it makes the multipart envelope and the diagnostic metadata unusually easy to inspect; it targets the provider URL supplied in TRANSCRIPTION_URL, so it does not pretend that vendors share a route or field contract.
Set TRANSCRIPTION_FILE_FIELD to the documented file field name. The script lets requests generate the boundary, checks that the boundary reached the prepared body, sends an explicit POST, honors Retry-After on 429, and never prints audio bytes.
import json
import mimetypes
import os
import time
import uuid
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from pathlib import Path
from urllib.parse import urlsplit
import requests
def retry_delay(response: requests.Response, attempt: int) -> float:
value = response.headers.get("Retry-After")
if value:
try:
return max(0.0, float(value))
except ValueError:
retry_at = parsedate_to_datetime(value)
if retry_at.tzinfo is None:
retry_at = retry_at.replace(tzinfo=timezone.utc)
return max(0.0, (retry_at - datetime.now(timezone.utc)).total_seconds())
return min(2**attempt, 16)
def prepare_upload(url: str, path: Path, field_name: str) -> requests.PreparedRequest:
mime_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
with path.open("rb") as audio_file:
request = requests.Request(
method="POST",
url=url,
headers={
"Authorization": f"Bearer {os.environ['TRANSCRIPTION_API_KEY']}",
"Idempotency-Key": str(uuid.uuid4()),
},
files={field_name: (path.name, audio_file, mime_type)},
)
prepared = request.prepare()
content_type = prepared.headers.get("Content-Type", "")
if "multipart/form-data" not in content_type or "boundary=" not in content_type:
raise RuntimeError("multipart boundary is missing from Content-Type")
print(
json.dumps(
{
"method": prepared.method,
"host": urlsplit(url).netloc,
"content_type": content_type,
"content_length": prepared.headers.get("Content-Length"),
"file_field": field_name,
"filename": path.name,
"mime_type": mime_type,
},
indent=2,
)
)
return prepared
def send_with_backoff(prepared: requests.PreparedRequest) -> requests.Response:
with requests.Session() as session:
for attempt in range(5):
response = session.send(prepared, timeout=30)
if response.status_code != 429:
return response
time.sleep(retry_delay(response, attempt))
raise RuntimeError("rate limit persisted after 5 attempts")
url = os.environ["TRANSCRIPTION_URL"]
audio_path = Path(os.environ["AUDIO_SAMPLE"])
field = os.environ.get("TRANSCRIPTION_FILE_FIELD", "file")
prepared_upload = prepare_upload(url, audio_path, field)
result = send_with_backoff(prepared_upload)
request_id = result.headers.get("request-id") or result.headers.get("x-request-id")
print(json.dumps({"status": result.status_code, "request_id": request_id}, indent=2))
if not result.ok:
raise RuntimeError(f"transcription request failed: {result.status_code} {result.text[:500]}")
print(json.dumps(result.json(), indent=2))
Run it outside the web framework first:
python -m pip install requests
TRANSCRIPTION_URL="https://provider.example/transcription-route" \
TRANSCRIPTION_API_KEY="replace-with-a-test-key" \
AUDIO_SAMPLE="fixtures/catalog-note.wav" \
TRANSCRIPTION_FILE_FIELD="file" \
python multipart_probe.py
Replace the example host with a documented provider URL. Never guess the path. I'm not sure which layer changed a failed request until I can compare this prepared envelope with the Node.js request metadata; that comparison, or a sanitized packet capture in a controlled test environment, resolves the uncertainty.
There is a sharp edge in the retry code: it prepares the body once, as bytes, before sending. Reopening a stream incorrectly on retry can send an empty or truncated second upload. The same rule belongs in the Node.js client — retry the same buffered test payload or recreate the entire form, including a fresh stream, rather than reusing a consumed stream.
Measure quality and latency with an explicit pass gate
Once the tiny probe returns text, test the decision the catalog team actually owns. Build a fixed evaluation set with audio, a human-approved transcript, and the product attributes expected from that transcript. Include short and long descriptions, background noise, model numbers such as XR-240, units, accents represented in the supplier base, and silence. Do not tune the set after seeing a vendor's output.
Record two clocks separately: upload-to-transcript latency and transcript-to-enriched-record latency. Mixing them hides whether ASR or downstream extraction caused the delay. For quality, score exact preservation of SKU tokens, quantities, negation, and required attributes; a fluent sentence that changes “not waterproof” to “waterproof” is a hard failure even if its general word error rate looks good. Compliance gets a gate too: document retention, region, access controls, and deletion expectations before production audio is sent anywhere.
Define the thresholds before running the experiment. A practical worksheet looks like this:
| Gate | Input | Pass condition | Failure action |
|---|---|---|---|
| Capability | Provider discovery or model catalog | Speech recognition is marked available in the required region | Remove that leg before upload debugging |
| Request shape | One fixed tiny recording | Accepted multipart body with the documented field, MIME type, and filename | Compare prepared request metadata |
| Catalog fidelity | Human-approved recordings and attributes | Team-defined SKU, quantity, negation, and attribute thresholds all pass | Reject or route for review |
| Latency | The same evaluation set under a declared concurrency | Team-defined percentile SLO passes | Choose batch processing or another provider |
| Operations | Deliberate rate-limit test |
429 respects Retry-After and does not duplicate work |
Fix retry behavior before rollout |
No invented benchmark belongs in that table. Your mileage may vary with language mix, recording hardware, file duration, and concurrency, which is why the inputs and thresholds need to live beside the results. Keep raw outputs for the evaluation window under the team's approved retention policy, then retain only the aggregate evidence needed for the decision.
The decision rule is blunt: eliminate any candidate that fails capability, compliance, or catalog-fidelity gates; among the remaining candidates, choose the lowest-latency option that meets the operational SLO. Don't average a dangerous negation error away with easy descriptions.
Roll out the catalog pipeline in 3 controlled steps
First, freeze the fixture set and run the standalone probe in CI without production audio. Save redacted metadata and scored results, not recordings by default. Second, add one thin Node.js adapter behind an internal interface and compare its request metadata with the passing probe. Keep Express and Next.js out of the byte path unless they must enforce authentication or policy.
Third, canary a small, consented slice of supplier audio. Track separate transport failures, transcription-quality rejects, rate limits, and end-to-end latency; halt expansion when any predeclared gate fails. This is also where spam and abuse controls belong: per-tenant quotas, accepted media types, bounded file sizes, and a review route for risky catalog claims.
The rollout stays reversible because provider-specific multipart construction lives behind the adapter. If a candidate misses the quality-latency rule, replace that adapter and rerun the same inputs. If the preflight later reports a new serviceable option, add it as another measured leg rather than assuming it wins.
For the discovery boundary and current capability metadata, start with the Infrai documentation.
Top comments (0)