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

@ -0,0 +1,207 @@
// server/supabase/functions/stt-proxy/handler.ts
// stt-proxy use case. All IO (auth, quota, dictionary, providers) is injected as ports.
//
// Order matters:
// 1. authenticate
// 2. parse and validate the audio input (4xx before any quota work)
// 3. reserve one quota unit before any provider work (429 when denied)
// 4. run the provider chain (runSttChain)
// 5. finalize the reservation, then answer
//
// Billing rule: once any provider has answered 2xx it has charged for the audio, so
// the reservation is consumed even when no usable transcript came back. Only when
// no provider answered 2xx is the unit refunded. An empty transcript is a real
// "no speech" result: it is never retried on the next paid provider and is answered
// with a fixed `stt_no_speech` code (clients reject a 200 with an empty transcript).
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
import {
type DictionaryHints,
type NormalizedSttResult,
type SttFailureCode,
SttInputError,
validateSttAudio,
} from '../_shared/stt-contract.ts'
import type { SttProvider, SttProviderInput } from './providers.ts'
export interface SttUser {
id: string
}
export type AuthenticateResult =
| { ok: true; user: SttUser }
| { ok: false; response: Response }
export interface SttQuotaReservationSnapshot {
allowed: boolean
reservationId: string | null
status: 'reserved' | 'completed' | 'released' | 'denied'
current: number
limit: number
period: string
tier: string
overageCredits: number
}
/** Reserve-then-finalize quota lease (reserve_stt_quota / finalize_stt_quota). */
export interface SttQuotaPort {
reserve(userId: string, reservationId: string): Promise<SttQuotaReservationSnapshot>
finalize(reservationId: string, succeeded: boolean): Promise<'completed' | 'released'>
}
export interface SttDictionaryPort {
hintsFor(userId: string): Promise<DictionaryHints>
}
export interface SttProxyLogger {
warn(message: string, meta: Record<string, unknown>): void
error(message: string, meta: Record<string, unknown>): void
}
export interface SttProxyDeps {
authenticate(req: Request): Promise<AuthenticateResult>
quota: SttQuotaPort
dictionary: SttDictionaryPort
/** Called per request so rotated secrets apply without a redeploy. */
providers(): readonly SttProvider[]
logger: SttProxyLogger
newReservationId?: () => string
}
export interface SttAttempt {
provider: string
failure: SttFailureCode
}
export type SttChainResult =
| { kind: 'ok'; result: NormalizedSttResult; billed: true; attempts: SttAttempt[] }
| { kind: 'no_speech'; provider: string; billed: true; attempts: SttAttempt[] }
| { kind: 'failed'; billed: boolean; unavailable: boolean; attempts: SttAttempt[] }
/**
* Try providers in order. Stops at the first 2xx answer with a transcript (`ok`) or
* with an empty transcript (`no_speech`); falls through only on retryable failures.
* `billed` is true when any provider answered 2xx.
*/
export async function runSttChain(
providers: readonly SttProvider[],
input: SttProviderInput,
): Promise<SttChainResult> {
const attempts: SttAttempt[] = []
let billed = false
let sawFailure = false
let sawUnavailable = false
for (const provider of providers) {
const outcome = await provider.transcribe(input).catch(() => ({
kind: 'retryable' as const,
failure: 'error' as const,
billed: false,
unavailable: false,
}))
if (outcome.kind === 'ok') {
return { kind: 'ok', result: outcome.result, billed: true, attempts }
}
if (outcome.kind === 'no_speech') {
return { kind: 'no_speech', provider: provider.id, billed: true, attempts }
}
attempts.push({ provider: provider.id, failure: outcome.failure })
billed ||= outcome.billed
if (outcome.unavailable) sawUnavailable = true
else sawFailure = true
}
// Nothing configured, or only explicit "unavailable" answers → 503; otherwise 502.
const unavailable = attempts.length === 0 || (!sawFailure && sawUnavailable)
return { kind: 'failed', billed, unavailable, attempts }
}
function json(status: number, payload: unknown): Response {
return new Response(JSON.stringify(payload), {
status,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
}
export function createSttProxyHandler(deps: SttProxyDeps): (req: Request) => Promise<Response> {
const { quota, logger } = deps
const newReservationId = deps.newReservationId ?? (() => crypto.randomUUID())
return async (req: Request): Promise<Response> => {
const preflight = handleCorsPreflightRequest(req)
if (preflight) return preflight
if (req.method !== 'POST') return json(405, { error: 'Method not allowed' })
let reservationId: string | null = null
let billed = false
try {
// 1) auth
const auth = await deps.authenticate(req)
if (!auth.ok) return auth.response
const user = auth.user
// 2) input
const formData = await req.formData()
const audio = formData.get('audio') ?? formData.get('file')
const languageCode = String(formData.get('language_code') ?? 'ko')
if (!(audio instanceof Blob)) return json(400, { error: 'Missing audio field' })
let audioInput: ReturnType<typeof validateSttAudio>
try {
audioInput = validateSttAudio(audio, languageCode)
} catch (error) {
if (error instanceof SttInputError) return json(error.status, { error: error.code })
throw error
}
// 3) Reserve before provider work. The DB advisory lock closes concurrent
// quota races; crashed provider work is reclaimed when the lease expires.
const reservation = await quota.reserve(user.id, newReservationId())
if (!reservation.allowed || !reservation.reservationId || reservation.status !== 'reserved') {
return json(429, {
error: 'quota_exceeded',
current: reservation.current,
limit: reservation.limit,
period: reservation.period,
tier: reservation.tier,
overage_credits: reservation.overageCredits,
})
}
reservationId = reservation.reservationId
// 4) providers
const hints = await deps.dictionary.hintsFor(user.id)
const chain = await runSttChain(deps.providers(), { audio, ...audioInput, hints })
billed = chain.billed
// 5a) Fail closed. Refund only when no provider billed the audio.
if (chain.kind === 'failed') {
logger.warn('stt-proxy all providers failed', { attempts: chain.attempts, billed: chain.billed })
await quota.finalize(reservationId, chain.billed).catch(() => undefined)
reservationId = null
const status = chain.unavailable ? 503 : 502
const error = status === 503 ? 'stt_provider_unavailable' : 'stt_upstream_failed'
return json(status, { error, attempts: chain.attempts })
}
// 5b) A provider answered 2xx: consume the exact pre-provider reservation.
const finalStatus = await quota.finalize(reservationId, true)
if (finalStatus !== 'completed') {
throw new Error('STT quota reservation was reclaimed before completion.')
}
reservationId = null
if (chain.kind === 'no_speech') {
return json(422, { error: 'stt_no_speech', attempts: chain.attempts })
}
return json(200, chain.result)
} catch (err) {
if (reservationId) {
await quota.finalize(reservationId, billed).catch(() => undefined)
}
logger.error('stt-proxy failed', { kind: err instanceof Error ? err.name : typeof err })
return json(500, { error: 'internal_error' })
}
}
}