fix(realtime-token): spend the session quota only after a token is minted and stop leaking provider errors
This commit is contained in:
parent
83c2deb561
commit
2251fe2da1
7 changed files with 673 additions and 155 deletions
176
server/supabase/functions/realtime-token/handler.ts
Normal file
176
server/supabase/functions/realtime-token/handler.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
// server/supabase/functions/realtime-token/handler.ts
|
||||
// realtime-token 유스케이스. IO는 모두 포트(인터페이스)로 주입받는다.
|
||||
//
|
||||
// 순서가 핵심이다:
|
||||
// 1. 인증
|
||||
// 2. 쿼터 조회(읽기 전용) → 티어/모델 403, 소진 429
|
||||
// 3. 공급자 키 확인 → 없으면 503 (쿼터를 건드리기 전)
|
||||
// 4. OpenAI client secret 발급 → 실패하면 502 (쿼터를 건드리기 전)
|
||||
// 5. 발급에 성공한 뒤에만 세션 쿼터를 원자적으로 소비 → 거부되면 429, 토큰은 버린다
|
||||
// 발급된 ephemeral secret은 연결에 쓰이기 전까지 비용이 없고 곧 만료되므로,
|
||||
// 소비가 거부되어 버려져도 손실이 없다. 반대로 소비를 먼저 하면 공급자 장애 시
|
||||
// 환불 수단 없이 세션이 차감된다.
|
||||
//
|
||||
// 공급자 에러 본문(조직 ID, 마스킹된 키 힌트 등)은 절대 응답에 싣지 않는다.
|
||||
// 상태 코드만 서버 로그에 남기고 고정 코드를 반환한다.
|
||||
|
||||
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
|
||||
import {
|
||||
buildRealtimeSessionConfig,
|
||||
type RealtimeSessionConfig,
|
||||
type RealtimeTier,
|
||||
type RealtimeTokenRequest,
|
||||
resolveRealtimeModel,
|
||||
} from './policy.ts'
|
||||
|
||||
export interface RealtimeUser {
|
||||
id: string
|
||||
}
|
||||
|
||||
export type AuthenticateResult =
|
||||
| { ok: true; user: RealtimeUser }
|
||||
| { ok: false; response: Response }
|
||||
|
||||
export interface RealtimeQuotaSnapshot {
|
||||
allowed: boolean
|
||||
current: number
|
||||
limit: number
|
||||
period: string
|
||||
tier: RealtimeTier
|
||||
overageCredits: number
|
||||
}
|
||||
|
||||
export interface RealtimeQuotaConsumeResult {
|
||||
allowed: boolean
|
||||
current: number
|
||||
limit: number
|
||||
overageCredits: number
|
||||
}
|
||||
|
||||
/** 세션 쿼터 포트 — check는 부작용 없음, consume은 원자적 1회 차감. */
|
||||
export interface RealtimeQuotaPort {
|
||||
check(userId: string): Promise<RealtimeQuotaSnapshot>
|
||||
consume(userId: string, tier: RealtimeTier): Promise<RealtimeQuotaConsumeResult>
|
||||
}
|
||||
|
||||
export type ClientSecretResult =
|
||||
| { ok: true; data: Record<string, unknown> }
|
||||
| { ok: false; reason: 'upstream_status'; status: number }
|
||||
| { ok: false; reason: 'network' | 'invalid_response' }
|
||||
|
||||
/** Realtime 공급자 포트 — 에러 본문은 포트 밖으로 내보내지 않는다. */
|
||||
export interface RealtimeProviderPort {
|
||||
isConfigured(): boolean
|
||||
mintClientSecret(userId: string, session: RealtimeSessionConfig): Promise<ClientSecretResult>
|
||||
}
|
||||
|
||||
export interface RealtimeLogger {
|
||||
error(message: string, meta: Record<string, unknown>): void
|
||||
}
|
||||
|
||||
export interface RealtimeTokenDeps {
|
||||
authenticate(req: Request): Promise<AuthenticateResult>
|
||||
quota: RealtimeQuotaPort
|
||||
provider: RealtimeProviderPort
|
||||
logger: RealtimeLogger
|
||||
}
|
||||
|
||||
function json(
|
||||
status: number,
|
||||
payload: Record<string, unknown>,
|
||||
): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
async function readBody(req: Request): Promise<RealtimeTokenRequest> {
|
||||
const parsed: unknown = await req.json().catch(() => ({}))
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? parsed as RealtimeTokenRequest
|
||||
: {}
|
||||
}
|
||||
|
||||
export function createRealtimeTokenHandler(deps: RealtimeTokenDeps): (req: Request) => Promise<Response> {
|
||||
const { quota, provider, logger } = deps
|
||||
|
||||
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' })
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await deps.authenticate(req)
|
||||
if (!auth.ok) return auth.response
|
||||
const user = auth.user
|
||||
|
||||
const body = await readBody(req)
|
||||
|
||||
// 1단계: 티어 + 세션 쿼터 확인 (읽기 전용)
|
||||
const snapshot = await quota.check(user.id)
|
||||
const tier = snapshot.tier
|
||||
|
||||
const resolution = resolveRealtimeModel(tier, body.model)
|
||||
if (!resolution.allowed) {
|
||||
return json(403, {
|
||||
error: resolution.error,
|
||||
tier,
|
||||
requested: resolution.requested,
|
||||
allowed: resolution.allowedModels,
|
||||
})
|
||||
}
|
||||
|
||||
if (!snapshot.allowed) {
|
||||
return json(429, {
|
||||
error: 'quota_exceeded',
|
||||
current: snapshot.current,
|
||||
limit: snapshot.limit,
|
||||
period: snapshot.period,
|
||||
tier,
|
||||
overage_credits: snapshot.overageCredits,
|
||||
})
|
||||
}
|
||||
|
||||
// 2단계: 공급자 가용성 — 쿼터 차감 전에 확인
|
||||
if (!provider.isConfigured()) {
|
||||
return json(503, { error: 'not_configured', message: 'OPENAI_API_KEY 미설정' })
|
||||
}
|
||||
|
||||
// 3단계: OpenAI ephemeral client secret 발급
|
||||
const session = buildRealtimeSessionConfig(body, resolution.model)
|
||||
const minted = await provider.mintClientSecret(user.id, session)
|
||||
if (!minted.ok) {
|
||||
logger.error('realtime-token provider failed', {
|
||||
reason: minted.reason,
|
||||
...(minted.reason === 'upstream_status' ? { status: minted.status } : {}),
|
||||
})
|
||||
const error = minted.reason === 'invalid_response'
|
||||
? 'provider_invalid_response'
|
||||
: 'provider_request_failed'
|
||||
return json(502, { error })
|
||||
}
|
||||
|
||||
// 4단계: 발급 성공 후에만 세션 1회를 원자적으로 소비
|
||||
const consumed = await quota.consume(user.id, tier)
|
||||
if (!consumed.allowed) {
|
||||
return json(429, {
|
||||
error: 'quota_exceeded',
|
||||
current: consumed.current,
|
||||
limit: consumed.limit,
|
||||
tier,
|
||||
overage_credits: consumed.overageCredits,
|
||||
})
|
||||
}
|
||||
|
||||
return json(200, { ...minted.data, model: resolution.model, tier })
|
||||
} catch (err) {
|
||||
logger.error('realtime-token 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