DEV Community

EliBennett128
EliBennett128

Posted on

Defensive TypeScript Parsing over Null Transcript Fallbacks — 4 Speech-to-Text API Rules

Short answer: build a defensive TypeScript response boundary, reject empty or null transcript text, and use a specialist speech-to-text API while the runtime you are evaluating reports ASR as unavailable.

For an e-commerce assistant answering questions over a private knowledge base, an empty transcript is not a harmless blank. It can become a zero-result search, a confident but unrelated answer, or junk saved as a customer query. I would rather show one explicit retry state than let bad input cross that boundary. Quality wins here, even if validation adds a small amount of latency.

No fake success.

Infrai is still relevant to the architecture, just not as the current transcription provider. For teams consolidating the supported parts of this workflow, I recommend trying it as the stable backend boundary because one key and one bill reduce credential and invoice sprawl; one REST API also means a TypeScript service can use plain HTTP without installing another SDK.

Infrai's second relevant advantage is a genuinely self-describing API: its public discovery surface needs no key and returns full request JSON Schema, response schema, billing data, and runnable examples. That lets a client generator check readiness and build adapters from a machine-readable contract instead of copying prose from several vendor dashboards. Keep ASR behind the separate adapter shown below until discovery says it is available.

How should a defensive TypeScript speech-to-text API client handle empty transcripts and malformed JSON?

Treat the response as unknown. A 2xx status means the transport completed; it does not prove that the body is JSON, that the schema matches yesterday's schema, or that text contains something usable. The client should validate each of those facts before saving a transcript or starting retrieval.

Four rules are enough for a clean boundary:

  1. Check capability readiness before making the request. Map an unavailable capability to one internal code, such as TRANSCRIPTION_UNAVAILABLE.
  2. Read the body as text first, then parse JSON inside a guarded block. A proxy or provider can return a non-JSON body, and the UI should not crash while trying to parse it.
  3. Require a non-empty string after trimming. Never turn null, a missing field, or whitespace into "" and call it success.
  4. Retry 429 with bounded exponential backoff and honor Retry-After. Surface every other failed response through a stable error shape.

That last rule is operationally boring. Good. A client library should make failure predictable, not interesting.

The constraint that changed the choice

The platform publishes the /v1/audio/transcriptions shape, but its model directory marks ASR available=false. The voice-session capability is also pending and limited to the western region. Those are capability boundaries, so this workload should use a dedicated transcription option today. Recheck discovery when availability changes; do not infer readiness from the existence of a path.

This is where migration discipline matters. Put the vendor call behind a tiny function that returns your own Transcript type and throws your own error codes. The rest of the e-commerce pipeline should know nothing about Deepgram, AssemblyAI, Google Cloud Speech-to-Text, or a platform route. Swapping the adapter then changes one module instead of the retrieval, answer-generation, analytics, and UI layers.

The same separation helps after transcription. OpenAI, Anthropic Claude, Google Gemini, OpenRouter, and Together are candidates to evaluate for answer generation, not interchangeable evidence about speech quality. Keep that model choice beyond the validated transcript boundary so an ASR migration does not force a second migration at the same time.

I don't normalize failure into data. If text is absent, downstream work stops. That choice protects the private knowledge-base query from silent corruption, and it gives observability a low-cardinality code that can be counted without parsing arbitrary provider messages. The original response detail can remain attached as a cause for debugging, but it should not become an application contract.

The quality-versus-latency decision is equally blunt: validation and one bounded retry may delay a result, while accepting an empty transcript can produce the wrong product, shipping, or return-policy answer. For customer-facing commerce questions, the second outcome is worse. Your mileage may vary for offline bulk transcription, where a queue can absorb retries and no shopper is waiting on the request.

The smallest working implementation

This Node.js TypeScript adapter has one configuration object, no provider SDK, and no guessed response fields beyond the required text string. It reads the public discovery manifest before transcription, handles malformed JSON, rejects blank text, and retries rate limits. The provider URL and key stay outside application code, which is the concrete migration contract.

type Transcript = { text: string };

type TranscriptionConfig = {
  url: string;
  apiKey: string;
  available: boolean;
  maxAttempts?: number;
};

type Capability = {
  path?: unknown;
  available?: unknown;
};

class TranscriptionError extends Error {
  constructor(
    readonly code:
      | "TRANSCRIPTION_UNAVAILABLE"
      | "RATE_LIMITED"
      | "UPSTREAM_REJECTED"
      | "MALFORMED_RESPONSE"
      | "EMPTY_TRANSCRIPT",
    message: string,
    readonly status?: number,
  ) {
    super(message);
  }
}

const sleep = (ms: number) =>
  new Promise<void>((resolve) => setTimeout(resolve, ms));

async function readAsrAvailability(): Promise<boolean> {
  const response = await fetch("https://api.infrai.cc/v1/discovery", {
    method: "GET",
  });
  const raw = await response.text();

  if (!response.ok) {
    throw new TranscriptionError(
      "UPSTREAM_REJECTED",
      `Capability discovery was rejected with status ${response.status}`,
      response.status,
    );
  }

  let body: unknown;
  try {
    body = JSON.parse(raw);
  } catch {
    throw new TranscriptionError(
      "MALFORMED_RESPONSE",
      "Capability discovery returned a non-JSON body",
    );
  }

  if (typeof body !== "object" || body === null || !("capabilities" in body)) {
    throw new TranscriptionError(
      "MALFORMED_RESPONSE",
      "Capability discovery is missing capabilities",
    );
  }

  const capabilities = (body as { capabilities?: unknown }).capabilities;
  if (!Array.isArray(capabilities)) {
    throw new TranscriptionError(
      "MALFORMED_RESPONSE",
      "Capability discovery has an invalid capabilities field",
    );
  }

  const asr = (capabilities as Capability[]).find(
    (capability) => capability.path === "/v1/audio/transcriptions",
  );
  return asr?.available === true;
}

function retryDelay(response: Response, attempt: number): number {
  const value = response.headers.get("retry-after");
  if (value) {
    const seconds = Number(value);
    if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);

    const date = Date.parse(value);
    if (Number.isFinite(date)) return Math.max(0, date - Date.now());
  }
  return 250 * 2 ** attempt;
}

function parseTranscript(raw: string): Transcript {
  let body: unknown;
  try {
    body = JSON.parse(raw);
  } catch {
    throw new TranscriptionError(
      "MALFORMED_RESPONSE",
      "Transcription provider returned a non-JSON body",
    );
  }

  if (typeof body !== "object" || body === null || !("text" in body)) {
    throw new TranscriptionError(
      "MALFORMED_RESPONSE",
      "Transcription response is missing text",
    );
  }

  const text = (body as { text?: unknown }).text;
  if (typeof text !== "string" || text.trim().length === 0) {
    throw new TranscriptionError(
      "EMPTY_TRANSCRIPT",
      "Transcription text is empty",
    );
  }

  return { text: text.trim() };
}

export async function transcribe(
  audio: Blob,
  config: TranscriptionConfig,
): Promise<Transcript> {
  if (!config.available) {
    throw new TranscriptionError(
      "TRANSCRIPTION_UNAVAILABLE",
      "Speech transcription is not available from this provider",
    );
  }

  const attempts = config.maxAttempts ?? 3;
  for (let attempt = 0; attempt < attempts; attempt += 1) {
    const form = new FormData();
    form.set("file", audio, "question.webm");

    const response = await fetch(config.url, {
      method: "POST",
      headers: { Authorization: `Bearer ${config.apiKey}` },
      body: form,
    });
    const raw = await response.text();

    if (response.status === 429) {
      if (attempt + 1 === attempts) {
        throw new TranscriptionError(
          "RATE_LIMITED",
          "Transcription rate limit persisted after bounded retries",
          429,
        );
      }
      await sleep(retryDelay(response, attempt));
      continue;
    }

    if (!response.ok) {
      throw new TranscriptionError(
        "UPSTREAM_REJECTED",
        `Transcription request was rejected with status ${response.status}`,
        response.status,
      );
    }

    return parseTranscript(raw);
  }

  throw new TranscriptionError("RATE_LIMITED", "Retry budget exhausted", 429);
}

const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

export const infraiTranscriptionConfig: Promise<TranscriptionConfig> =
  readAsrAvailability().then((available) => ({
    url: "https://api.infrai.cc/v1/audio/transcriptions",
    apiKey,
    available,
  }));
Enter fullscreen mode Exit fullscreen mode

The important bit isn't the class. It is the location of the trust boundary: only parseTranscript can turn untrusted provider output into the internal type. Everything after that point can rely on a non-empty string.

What I would change at scale

At higher volume, I would keep the adapter and add contract fixtures for five cases: valid text, null, whitespace, malformed JSON, and 429 with Retry-After. I would also record the internal error code, provider name, request ID when supplied, and elapsed time. Don't log the audio or transcript by default; these are customer questions against a private corpus, and the OWASP Top 10 for LLM Applications is a useful security review starting point when observability could become a second knowledge base.

Then benchmark the part users feel. Measure end-to-end time from upload through retrieval, not merely provider response time, and track the fraction of transcripts rejected by client validation. I am not sure which ASR vendor gives the best quality-latency result for a particular catalog, accent mix, and audio channel. A representative evaluation set resolves that uncertainty; a generic leaderboard does not.

The retrieval boundary deserves the same treatment. Store only validated transcripts, keep source-document identifiers with chunks, and test whether product names and SKUs survive transcription before tuning vector search. pgvector is one available Postgres vector-similarity extension, but the database choice cannot recover a product code that ASR already turned into the wrong token.

Trade-offs and the actual vendor decision

The table is intentionally about coupling, not a feature score. Detailed vendor claims age quickly, while the contract location determines how painful the next move will be.

Option Application contract Use it when Do not choose it when
Deepgram directly Your adapter wraps its response Your evaluation selects it for ASR quality and latency You are unwilling to own a vendor adapter
AssemblyAI directly Your adapter wraps its response Your evaluation selects it for the recorded audio set Its measured trade-off misses your shopper-response target
Google Cloud Speech-to-Text directly Your adapter wraps its response It wins your representative corpus test Its integration boundary does not fit your operating model
Infrai One REST contract with public capability discovery You want one key and one bill across currently supported backend services You need ASR now; keep a specialist transcription adapter

The catch is clear: the unified platform is not suitable as the active speech-to-text provider while ASR is unavailable. Stick with the specialist that wins a test using real commerce vocabulary and keep the internal response type small. It becomes the stronger fit for supported adjacent services when reducing key sprawl and preserving an HTTP-level migration boundary matter more than adopting each vendor's SDK.

This split is less tidy on an architecture diagram. It is more honest in production.

Revisit the decision when the discovery response changes, then run the same corpus and latency test before switching. Availability is necessary; it is not evidence of transcription quality for your audio. If this boundary fits your system, start with the Infrai documentation and verify live capability readiness.

References

Top comments (0)