fix(classify): stop free/anon users flooding the premium-gated classify-event endpoint (#4865)#4869
Conversation
…fy-event endpoint (#4865) #4779 premium-gated /api/intelligence/v1/classify-event server-side, but the client kept enqueueing a classification RPC for EVERY incoming headline regardless of entitlement: - signed-in free users: ~95k tier_403s/day (331 users) — the batch loop had no 403 handling at all, so each rejection resolved null and the loop kept firing at full cadence; - anonymous users: ~475k 401s/day (800 IPs) — the 120s-pause loop retried forever as new headlines refilled the queue. Fix (two layers, both resolving to the existing keyword-classification fallback): 1. Enqueue gate: classifyWithAIUncached consults a new pure session gate (src/services/classify-gate.ts) wired to panel-gating's hasPremiumAccess dual signal — anon/free principals make ZERO requests. Fails OPEN if the probe throws (server still gates; a client gating bug must never silence Pro classification). 2. 403 branch in the batch loop: deterministic entitlement rejection → suppress all attempts for 15 min, drain batch + queue to nulls, never retry the job. Covers entitlement-signal drift (probe says entitled, server disagrees) that would otherwise recreate the flood at headline-arrival rate; the timed window self-heals a mid-session upgrade with a worst case of one probe request per 15 min. The gate lives in a pure zero-import module because threat-classifier imports @/utils and cannot load under tsx --test; the wiring is pinned by source-grep assertions (project pattern). Tests: tests/classify-entitlement-gate.test.mts (11 cases, failing-first); ai-classify-queue + news-classifier suites green (86); typecheck + biome clean; docs stats regenerated for the new service module. Claude-Session: https://claude.ai/code/session_01Ex9zkdxdbxYMogHmEK2YXZ
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Greptile SummaryThis PR adds a two-layer client-side gate to stop free and anonymous users from flooding the premium-gated
Confidence Score: 4/5Safe to merge — the change is additive, fails open for paying users, and the server gate remains the authoritative backstop. The two-layer gate logic is correct: the probe is called fresh on every enqueue, suppression is properly tested including expiry and post-expiry probe re-evaluation, and the 403 drain correctly resolves all in-flight and queued promises before setting batchInFlight to false. The one gap is that consecutive429s is not reset when a 403 fires, which could cause an unnecessarily long first 429 backoff after the suppression window expires in sessions that previously hit rate-limiting. src/services/threat-classifier.ts — specifically the 403 handler and its interaction with the consecutive429s counter. Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant H as Headline Arrives
participant CU as classifyWithAIUncached
participant CG as classify-gate
participant BQ as batchQueue
participant FL as flushBatch
participant SV as classify-event API
H->>CU: classifyWithAI(title, variant)
CU->>CG: canAttemptAiClassification()
alt entitlementProbe() false OR suppressed
CG-->>CU: false
CU-->>H: resolve(null) keyword fallback
else allowed
CG-->>CU: true
CU->>BQ: push(job)
CU->>FL: scheduleBatch()
FL->>SV: classifyEvent(title)
alt 200 OK
SV-->>FL: ClassifyEventResponse
FL-->>H: resolve(toThreat(resp))
else 403 Forbidden
SV-->>FL: 403
FL->>CG: suppressAiClassification() +15 min
FL-->>BQ: splice(0) drain all resolve(null)
FL-->>H: resolve(null) keyword fallback
else 401 / 429 / 5xx
SV-->>FL: error
FL->>FL: "batchPaused=true exponential backoff"
FL-->>H: retry or null
end
end
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant H as Headline Arrives
participant CU as classifyWithAIUncached
participant CG as classify-gate
participant BQ as batchQueue
participant FL as flushBatch
participant SV as classify-event API
H->>CU: classifyWithAI(title, variant)
CU->>CG: canAttemptAiClassification()
alt entitlementProbe() false OR suppressed
CG-->>CU: false
CU-->>H: resolve(null) keyword fallback
else allowed
CG-->>CU: true
CU->>BQ: push(job)
CU->>FL: scheduleBatch()
FL->>SV: classifyEvent(title)
alt 200 OK
SV-->>FL: ClassifyEventResponse
FL-->>H: resolve(toThreat(resp))
else 403 Forbidden
SV-->>FL: 403
FL->>CG: suppressAiClassification() +15 min
FL-->>BQ: splice(0) drain all resolve(null)
FL-->>H: resolve(null) keyword fallback
else 401 / 429 / 5xx
SV-->>FL: error
FL->>FL: "batchPaused=true exponential backoff"
FL-->>H: retry or null
end
end
Reviews (1): Last reviewed commit: "docs: bump services count for classify-g..." | Re-trigger Greptile |
| if (statusCode === 403) { | ||
| // #4865: a 403 is a deterministic entitlement rejection for this | ||
| // principal — retrying per headline recreated the flood (the | ||
| // pre-fix loop resolved null and kept firing at full cadence). | ||
| // Suppress ALL attempts for the gate window, drain everything to | ||
| // the keyword fallback, and let the gate re-probe after the | ||
| // window (self-heals a mid-session upgrade). | ||
| suppressAiClassification(); |
There was a problem hiding this comment.
The 403 handler does not reset
consecutive429s. If the session had previously encountered rate-limiting (bumping consecutive429s to N), the 15-minute suppression window restarts without clearing it. When the window expires and a newly entitled user's requests get a 429, the exponential backoff kicks off at BASE_PAUSE_MS * 2^N — potentially up to MAX_PAUSE_MS — instead of starting from the base. A successful response would reset the counter, but only if one gets through first. Resetting to 0 here aligns with the 403 representing an entitlement rejection rather than a rate-limit event.
| if (statusCode === 403) { | |
| // #4865: a 403 is a deterministic entitlement rejection for this | |
| // principal — retrying per headline recreated the flood (the | |
| // pre-fix loop resolved null and kept firing at full cadence). | |
| // Suppress ALL attempts for the gate window, drain everything to | |
| // the keyword fallback, and let the gate re-probe after the | |
| // window (self-heals a mid-session upgrade). | |
| suppressAiClassification(); | |
| if (statusCode === 403) { | |
| // #4865: a 403 is a deterministic entitlement rejection for this | |
| // principal — retrying per headline recreated the flood (the | |
| // pre-fix loop resolved null and kept firing at full cadence). | |
| // Suppress ALL attempts for the gate window, drain everything to | |
| // the keyword fallback, and let the gate re-probe after the | |
| // window (self-heals a mid-session upgrade). | |
| consecutive429s = 0; | |
| suppressAiClassification(); |
…top the anon 401 flood + 3x fan-out (#4913) (#4915) * fix(summarization): entitlement-gate the summarize provider chain — stop the anon 401 flood + 3x fan-out (#4913) #4675/#4687 premium-gated summarize-article's LLM spend server-side but the client kept dispatching for every principal: anon dashboards fanned out up to 3 doomed RPCs per attempt (ollama->groq->openrouter through the same gated endpoint) before landing on the browser-T5 fallback they'd get anyway — console/Sentry 401 noise on every anonymous /desktop session. Third instance of the server-gate-without-client-sweep class (#4865 classify-event, #4902 health). Same fix shape as #4869: - src/services/summarize-gate.ts — pure session gate (probe + timed 15-min suppression, fail-open on probe errors so a gating bug never silences Pro summaries). Separate state from classify-gate: a summarize 403 must not silence classification. - tryApiProvider consults the gate before any dispatch (single choke point — covers runApiChain and the beta fire-and-forget call); probe wired to panel-gating's hasPremiumAccess at module init. - Server 403 -> suppress the whole chain for a window (entitlement drift must not recreate the flood at news-refresh rate). 401 does NOT suppress: post-gate it only occurs on the signed-in boot-window race, and benching a Pro user 15min over a transient race is worse. - translateText stays ungated (mode=translate via plain newsClient is the server-allowed anon path). Free users keep summaries (browser T5) — this removes only the wasted requests. Tests: pure-gate suite + source-grep wiring pins (gate before RPC, 403 suppression, single call site, translate ungated); AGENTS.md service count + stats.json regen for the new module. Fixes #4913 Claude-Session: https://claude.ai/code/session_01YKrVoDp8TfEKLYXo83kPFV * fix(summarization): duck-type the 403 check — ApiError value import pulled the news RPC client into the main static graph The instanceof check imported ApiError as a VALUE from the generated news client, turning the previously type-only import into a static edge and failing the eager-chunk budget (rpc-client-news-v1 must stay lazy). Use the existing getRpcErrorStatusCode duck-type helper instead — same one threat-classifier uses for the same reason. Wiring pin updated to the new shape. Verified against a fresh VITE_VARIANT=full build: dashboard-eager-chunks 154/154. Claude-Session: https://claude.ai/code/session_01YKrVoDp8TfEKLYXo83kPFV
Summary
Closes #4865. After #4779 premium-gated
/api/intelligence/v1/classify-eventserver-side, the client kept enqueueing a classification RPC for every incoming headline regardless of entitlement:~570k wasted edge requests/day, and free/anon event classification silently failing where it used to be served (pre-gate baseline: ~130k anon 200s/day of LLM spend — the server gate is deliberate cost protection; the client never learned to stop asking).
Fix — two layers, both landing on the existing keyword-classification fallback
src/services/classify-gate.ts, new pure module):classifyWithAIUncachedconsults an entitlement probe wired to panel-gating'shasPremiumAccess(the dual-signal source of truth). Anon/free principals resolve straight to null — zero requests. Fails OPEN if the probe throws: the server still gates, and a client-side gating bug must never silence classification for paying users.The gate is a zero-import module because
threat-classifierimports@/utilsand cannot load undertsx --test; the wiring is pinned by source-grep assertions (existing project pattern).UX for free/anon users
Unchanged from the post-#4779 server reality: they get keyword classification (
classifyByKeyword), which is what the null-resolution fallback already produced — minus the half-million failing requests.Tests
tests/classify-entitlement-gate.test.mts— 11 cases, written failing-first: probe allow/deny/throw-fail-open, suppression window entry/expiry/probe-recheck, window-length guard, and three source-grep wiring assertions (gate beforebatchQueue.push, 403 → suppress + full drain, probe wired tohasPremiumAccess). Adjacent suites green (ai-classify-queue, news-classifier ×3 — 86 tests); fulltypecheck+ biome clean; docs stats regenerated for the new service module.Post-deploy verification
Axiom: free-user 403s and anon 401s on
/api/intelligence/v1/classify-eventshould collapse to ~0 within an hour of the frontend deploy reaching clients (stale tabs decay as sessions reload).https://claude.ai/code/session_01Ex9zkdxdbxYMogHmEK2YXZ