fix(stt-proxy): stop paid fan-out on empty transcripts and redact provider errors
This commit is contained in:
parent
1a4c39cb94
commit
1eb22af1f3
9 changed files with 1107 additions and 380 deletions
207
server/supabase/functions/stt-proxy/handler.ts
Normal file
207
server/supabase/functions/stt-proxy/handler.ts
Normal 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' })
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue