DEV Community

AidenSterling3417
AidenSterling3417

Posted on

Defensive Client Contracts for Node.js JSON Response Parsing

Short answer: A speech-to-text API client should treat HTTP success, JSON decoding, schema validation, and a non-empty transcript as four separate checks; only validated text should enter indexing or a prompt.

The operational constraint changes the design: an HTTP 200 with {"text": null} completed at the transport layer but produced no usable result. A Node.js or TypeScript type cannot validate that response at runtime. Put a defensive parser at the provider boundary, return an explicit outcome, and prove with fixtures that rejected input creates no chunks, vectors, or model calls.

Keep those checks boring. That's a feature.

How should a speech-to-text API client classify an empty transcript?

Start with four outcomes rather than one success flag: accepted text, empty transcript, malformed JSON, and invalid schema. They describe different evidence. Malformed JSON means decoding failed. Invalid schema means decoding succeeded but the value has the wrong shape or type. Empty means the expected field is null, "", or whitespace. Accepted means it is a string with at least one non-whitespace character after the application's chosen normalization.

This distinction matters before retrieval. If null is coerced with String(value), the literal word "null" can become a chunk; if an object is coerced, "[object Object]" can follow it. Both strings look non-empty to a naive guard. Once embedded, they are harder to distinguish from genuine content, and the transcript job may appear complete even though the user cannot search what they uploaded. The OWASP Top 10 for LLM Applications is useful context here: downstream model systems need explicit trust boundaries. The local parser is one such boundary, not a complete security program.

Do not guess alternate field names. An adapter may intentionally support several documented envelopes, but a generic parser that hunts through text, transcript, content, and result can bind to metadata that merely happens to be a string. Each envelope deserves its own adapter and fixtures. The shared application contract can stay small.

Outcome Local evidence Default action Forbidden downstream effect
Accepted Object contains trimmed, non-empty text Persist once, then enqueue indexing A second normalization policy
Empty Expected field is null, empty, or whitespace Request review or better input Chunks, embeddings, or prompts
Malformed Decoder rejects the body Record safe diagnostics; apply documented retry policy Treat raw bytes as text
Invalid schema JSON value violates the adapter contract Stop and surface an integration signal Guess another key or coerce a value

The catch is that strict rejection sacrifices salvage. It is not suitable when an irreplaceable recording must yield every recoverable fragment, or when live captions legitimately emit interim frames without text. In those cases, preserve the source under the applicable retention policy and add a separate review or pending_segment state. Don't weaken the batch-ingestion contract invisibly.

Put the runtime contract before the queue

The parser should accept the raw response body, not an already decoded dictionary. That preserves the difference between a syntax failure and a shape failure. It should also remain independent of HTTP retries, storage, and provider-specific authentication. Those decisions change at different speeds, and mixing them turns a small validation rule into a difficult integration test.

The example is Python because the same code can move from a notebook fixture into a production worker without changing the contract. A TypeScript implementation should expose the same discriminated states; the key point is runtime inspection of unknown, since compile-time annotations do not inspect response bytes.

from dataclasses import dataclass
from enum import Enum
import json
from typing import Any


class TranscriptStatus(str, Enum):
    ACCEPTED = "accepted"
    EMPTY = "empty_transcript"
    MALFORMED = "malformed_json"
    INVALID_SCHEMA = "invalid_schema"


@dataclass(frozen=True)
class TranscriptResult:
    status: TranscriptStatus
    text: str | None = None


def parse_transcript(body: str) -> TranscriptResult:
    try:
        payload: Any = json.loads(body)
    except json.JSONDecodeError:
        return TranscriptResult(TranscriptStatus.MALFORMED)

    if not isinstance(payload, dict):
        return TranscriptResult(TranscriptStatus.INVALID_SCHEMA)

    value = payload.get("text")
    if value is None:
        return TranscriptResult(TranscriptStatus.EMPTY)
    if not isinstance(value, str):
        return TranscriptResult(TranscriptStatus.INVALID_SCHEMA)

    normalized = value.strip()
    if not normalized:
        return TranscriptResult(TranscriptStatus.EMPTY)
    return TranscriptResult(TranscriptStatus.ACCEPTED, normalized)
Enter fullscreen mode Exit fullscreen mode

Notice what the function does not do. It does not retry, log the body, search for another key, persist state, or call an embedding model. It classifies one boundary. That narrowness is useful — provider adapters can change while the indexing worker continues to consume TranscriptResult.

I've used an HTTP 200 fixture as the first regression case because it catches the tempting response.ok shortcut immediately. No invented network behavior is required: feed the parser exact strings, then assert both its result and the absence of downstream work. A focused corpus can cover a valid object, null, empty and whitespace-only strings, a number, an array, a truncated object, and plain non-JSON text.

import pytest


@pytest.mark.parametrize(
    ("body", "expected"),
    [
        ('{"text":"hello"}', TranscriptStatus.ACCEPTED),
        ('{"text":null}', TranscriptStatus.EMPTY),
        ('{"text":""}', TranscriptStatus.EMPTY),
        ('{"text":"   "}', TranscriptStatus.EMPTY),
        ('{"text":42}', TranscriptStatus.INVALID_SCHEMA),
        ('[]', TranscriptStatus.INVALID_SCHEMA),
        ('{"text":', TranscriptStatus.MALFORMED),
        ('not json', TranscriptStatus.MALFORMED),
    ],
)
def test_parse_transcript(body: str, expected: TranscriptStatus) -> None:
    result = parse_transcript(body)
    assert result.status is expected
    if expected is not TranscriptStatus.ACCEPTED:
        assert result.text is None
Enter fullscreen mode Exit fullscreen mode

This is the notebook-to-prod checkpoint I care about. A notebook may print whatever came back; a production boundary must classify it deterministically. Fast fixtures also keep provider calls and prompt cost out of the inner test loop.

Test the requested state, not the request

Parser tests are necessary but too local. The user asked for searchable text, so the end-to-end assertion should describe that state. For an accepted fixture, confirm that one transcript record is committed, one indexing operation is emitted, and the searchable document remains associated with the upload's correlation ID. For every rejected fixture, confirm that zero chunks, zero vectors, and zero prompt calls exist. If vector search uses Postgres with pgvector, transaction boundaries and database constraints can protect the relationship between source and searchable record; the parser should not depend on that storage choice.

No transcript, no index.

An eval harness should keep transport, parsing, and product metrics apart. Transport counters answer whether requests completed. Parser counters show the distribution of accepted, empty, malformed, and invalid-schema outcomes. Product checks reconcile accepted uploads with indexed documents. Accuracy belongs in the harness too, using a consented corpus and a measure suited to the application; a RAG workflow should care about retrieval of names, identifiers, and domain terms, not merely whether some text exists.

Run the fixed corpus whenever the client adapter, normalization policy, queue consumer, or indexing path changes. Then replay representative audio in a controlled environment. Track latency and accepted characters per audio minute, but include rejected work in cost accounting as well — retries consume transcription and orchestration budget even when they never produce prompt context. A low cost per successful call can hide repeated failed attempts.

I'm not sure a universal retry count exists, because the correct decision depends on the API's documented error semantics and the value of the recording. Resolve that uncertainty with the provider contract and product policy, not a broad except block. Empty content usually needs different input or review; malformed content may follow a documented retry rule; invalid schema should raise an integration signal. Your mileage may vary for streaming, where finalization and ordering add states that a batch parser does not need.

Operate the boundary without leaking the recording

Observability should explain classification without copying sensitive content into logs. Record a correlation ID, adapter version, content type, body length, audio duration, latency, and outcome. Avoid raw audio, credentials, and full transcripts by default. If diagnostic retention is necessary, give it an explicit purpose, access policy, and deletion window rather than inheriting the general application log settings.

State transitions make silent loss visible. An upload can move from received to processing, then to accepted, empty, malformed, or invalid schema; only accepted may advance to indexed. Define indexed as confirmation of the searchable side effect, not merely successful queue publication. Store an idempotency key with the transition so a permitted retry cannot create duplicate transcript rows or embeddings.

The final choice is a risk trade-off. Batch RAG ingestion benefits from rejecting uncertain text before it contaminates retrieval. Live captions favor continuity and may tolerate a temporary empty segment. A compliance archive may require human review rather than another automated attempt. Measure the false-acceptance cost, the false-rejection cost, accepted-to-indexed reconciliation, and retry spend before copying this policy into another workload.

The reliable design is deliberately modest: parse once, validate at runtime, represent failure states explicitly, and assert the user's downstream result. An HTTP status is evidence about transport. It is not a transcript.

Sources

Top comments (0)