Skip to content

Audio Abuse Handling (PHON-167)

The production Speech Analysis surface (POST /api/audio/analyze, POST /api/audio/attribute) is unauthenticated and proxies to the Cloudflare Containers inference host — the most expensive compute PhonoLex runs. This page documents the guardrails: what the limits are, how to change them, how to read the metrics, and what to do under abuse.

The dev-gated endpoints (/transcribe, /compare, /pronounce, /feature-review, /acoustic) are not rate limited — they 404 in deployed environments (AUDIO_DEV_ENDPOINTS gate).

Limits

Per client IP (CF-Connecting-IP), enforced by the AudioRateLimiter Durable Object (packages/web/workers/src/durable/audioRateLimiter.ts — one DO instance per IP, durable counters, fixed minute + UTC-day windows with lazy reset):

Limit Env var Default Window
Burst AUDIO_RATE_PER_MIN 6 fixed 60 s window
Daily quota AUDIO_QUOTA_PER_DAY 60 UTC day
Worker→host timeout AUDIO_HOST_TIMEOUT_MS 120000 per request

The timeout is deliberately generous (120 s) so a container cold start (~60 s+, scale-to-zero) never trips it on a healthy host.

Fail-open by design: if the AUDIO_LIMITER binding is absent or the DO call errors, the request proceeds with a console.warn — limiter availability never blocks the product. Local dev (wrangler.dev.toml) has no binding, so local work is never limited.

Error semantics

Status Meaning Body
429 + Retry-After Minute limit: detail says "try again in a minute". Daily quota: detail says the daily limit is reached (resets midnight UTC). X-RateLimit-Scope: minute\|day distinguishes them. { detail }
503 Host cold start / not ready — the frontend auto-retries (warming loop). { warming: true, detail }
504 AUDIO_HOST_TIMEOUT_MS expired — distinct from cold start; the frontend does not auto-retry. { detail }
host 4xx Passed through as-is with the host's detail (e.g. 413 clip too long, 422 undecodable) — unchanged v6.1 behavior. { detail }

The frontend treats 429 as terminal for the request (analyzeProduction in packages/web/frontend/src/services/audioAnalysisApi.ts): the limit message is shown as an error notification, distinct from the cold-start copy, and the warming auto-retry loop never runs for it. The user can still retry manually.

Changing the limits

The env vars live in packages/web/workers/wrangler.toml ([vars] for production, [env.staging.vars] for staging). Change the values and redeploy the Worker — no DO state migration needed (limits are passed per-request, not stored). Removing a var falls back to the code defaults (6 / 60 / 120000).

Reading the metrics

Every production-surface audio request writes a server-emitted audio_request point to Workers Analytics Engine (ANALYTICS dataset; layout in analytics-events.md): blob7 = outcome (ok | rate_limited | quota_exceeded | host_error | timeout | invalid | warming), double6 = duration_ms. No audio content, no target words, and no IP ever enters analytics.

Weekly view (includes the "Audio requests by outcome" and "Audio 429s per day" sections):

CF_ACCOUNT_ID=... CF_API_TOKEN=... packages/web/workers/scripts/analytics-report.sh

What to watch:

  • rate_limited / quota_exceeded spikes → someone is hammering the surface.
  • sustained warming volume → cold starts dominating UX (consider min instances).
  • timeout / host_error → container health, not abuse.

Under abuse

  1. Confirm via the report (429 outcomes climbing).
  2. Drop AUDIO_RATE_PER_MIN / AUDIO_QUOTA_PER_DAY in wrangler.toml and redeploy (cd packages/web/workers && npx wrangler deploy — or land the change on develop/main and let CI deploy).
  3. For a targeted attacker, add a Cloudflare WAF rule on api.phonolex.com/api/audio/* (dashboard, no deploy) — the limiter is a cost guard, not a WAF.
  4. Rate limiting keys on CF-Connecting-IP; a distributed botnet needs the WAF/Bot-Management layer instead.