fix(stt-proxy): stop paid fan-out on empty transcripts and redact provider errors

This commit is contained in:
Yun Chan 2026-09-28 02:16:19 +09:00
parent 1a4c39cb94
commit 1eb22af1f3
9 changed files with 1107 additions and 380 deletions

View file

@ -204,3 +204,63 @@ export function createDeepgramSttUrl(languageCode: string, keyterms: readonly st
for (const keyterm of keyterms.slice(0, 50)) url.searchParams.append('keyterm', keyterm)
return url.toString()
}
/**
* Fixed failure codes reported to callers in `attempts`. Never a raw exception
* message: Deno fetch errors carry the request URL (the internal gateway host)
* and JSON parse errors quote part of the provider body.
*/
export type SttFailureCode =
| 'timeout'
| 'network_error'
| 'invalid_response'
| 'misconfigured'
| 'error'
| `http_${number}`
/**
* What one provider call produced.
*
* - `ok`: a 2xx answer with a real transcript.
* - `no_speech`: a 2xx answer with an empty transcript. The provider has billed the
* audio, so this is terminal: it is never retried on the next paid provider.
* - `retryable`: transport error, non-2xx status or a malformed 2xx body. `billed`
* is true when the provider answered 2xx (it charged us even though the body was
* unusable), so the quota reservation must be consumed, not refunded.
* `unavailable` marks an explicit "not available" answer (gateway 503).
*/
export type SttOutcome =
| { kind: 'ok'; result: NormalizedSttResult }
| { kind: 'no_speech' }
| { kind: 'retryable'; failure: SttFailureCode; billed: boolean; unavailable: boolean }
export function sttHttpFailure(status: number): SttFailureCode {
return `http_${Math.trunc(status)}`
}
/** Map a thrown transport error to a fixed code. The error text is never kept. */
export function sttTransportFailure(err: unknown): SttFailureCode {
if (!(err instanceof Error)) return 'error'
if (err.name === 'TimeoutError' || err.name === 'AbortError') return 'timeout'
// Deno fetch rejects DNS, connection and TLS failures with a TypeError.
if (err.name === 'TypeError') return 'network_error'
return 'error'
}
/**
* Classify a 2xx provider answer. The provider has already billed the audio, so
* every outcome here is `billed`. An empty transcript is a genuine "no speech"
* result; normalizeSttResult stays strict and is only used for real transcripts.
*/
export function sttOutcomeFromBilledAnswer(candidate: Readonly<Record<string, unknown>>): SttOutcome {
const transcript = candidate.transcript
if (typeof transcript !== 'string') {
return { kind: 'retryable', failure: 'invalid_response', billed: true, unavailable: false }
}
if (!transcript.trim()) return { kind: 'no_speech' }
try {
return { kind: 'ok', result: normalizeSttResult(candidate) }
} catch {
return { kind: 'retryable', failure: 'invalid_response', billed: true, unavailable: false }
}
}