Skip to content

fix(classify): stop free/anon users flooding the premium-gated classify-event endpoint (#4865)#4869

Merged
koala73 merged 2 commits into
mainfrom
fix/classify-event-free-403-flood-4865
Jul 5, 2026
Merged

fix(classify): stop free/anon users flooding the premium-gated classify-event endpoint (#4865)#4869
koala73 merged 2 commits into
mainfrom
fix/classify-event-free-403-flood-4865

Conversation

@koala73

@koala73 koala73 commented Jul 5, 2026

Copy link
Copy Markdown
Owner

Summary

Closes #4865. After #4779 premium-gated /api/intelligence/v1/classify-event server-side, the client kept enqueueing a classification RPC for every incoming headline regardless of entitlement:

cohort status volume (Jul 5, Axiom) mechanism
signed-in free (331 users) 403 95,645/day batch loop had NO 403 handling — resolved null, kept firing at full cadence
anonymous (801 IPs) 401 474,741/day 120s-pause loop retried forever as new headlines refilled the queue

~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

  1. Enqueue gate (src/services/classify-gate.ts, new pure module): classifyWithAIUncached consults an entitlement probe wired to panel-gating's hasPremiumAccess (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.
  2. 403 branch in the batch loop: a 403 is a deterministic entitlement rejection — suppress all AI-classification attempts for 15 minutes, drain the in-flight batch + entire queue to nulls, never retry the job. This covers entitlement-signal drift (probe says entitled, server disagrees), which 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 minutes — no auth-event plumbing.

The gate is a zero-import module because threat-classifier imports @/utils and cannot load under tsx --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 before batchQueue.push, 403 → suppress + full drain, probe wired to hasPremiumAccess). Adjacent suites green (ai-classify-queue, news-classifier ×3 — 86 tests); full typecheck + 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-event should collapse to ~0 within an hour of the frontend deploy reaching clients (stale tabs decay as sessions reload).

https://claude.ai/code/session_01Ex9zkdxdbxYMogHmEK2YXZ

…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
@mintlify

mintlify Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
WorldMonitor 🟢 Ready View Preview Jul 5, 2026, 1:38 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@vercel

vercel Bot commented Jul 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
worldmonitor Ignored Ignored Preview Jul 5, 2026 1:47pm

Request Review

@greptile-apps

greptile-apps Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a two-layer client-side gate to stop free and anonymous users from flooding the premium-gated /api/intelligence/v1/classify-event endpoint (~570k wasted edge requests/day post-#4779). The fix is clean and well-reasoned, with full test coverage.

  • Enqueue gate (classify-gate.ts): a new zero-import module holding an entitlement probe (wired to hasPremiumAccess) and a suppressedUntil timestamp; anon/free principals resolve immediately to null without any network attempt, and the probe fails open so a gating bug can never silence paying users.
  • 403 handler in flushBatch: on a server 403 the handler calls suppressAiClassification(), resolves the current job and all remaining batch + queued jobs to null, clears batchInFlight, and returns — the 15-minute timed window self-heals mid-session upgrades without requiring auth-event plumbing.

Confidence Score: 4/5

Safe 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

Filename Overview
src/services/classify-gate.ts New pure module implementing the two-layer session gate (entitlement probe + timed suppression). Logic is sound: fail-open on throw, suppression wins over probe, and the 15-minute window is guarded by a test.
src/services/threat-classifier.ts Adds gate check before enqueue and 403 handler with full drain. Logic is correct — batchInFlight is cleared before return so the finally block skips scheduleBatch. Minor: consecutive429s is not reset in the 403 path, which could cause inflated initial backoff after the suppression window expires.
tests/classify-entitlement-gate.test.mts 11 tests covering probe allow/deny/throw, suppression entry/expiry/post-expiry probe recheck, window-length guard, and three source-grep wiring assertions. Coverage is thorough.
docs/generated/stats.json Increments serviceTopLevelEntries from 194 to 195 to account for the new classify-gate.ts service module. Mechanical update.

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
Loading
%%{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
Loading

Reviews (1): Last reviewed commit: "docs: bump services count for classify-g..." | Re-trigger Greptile

Comment on lines +480 to +487
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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Suggested change
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();

@koala73
koala73 merged commit 3898b1a into main Jul 5, 2026
25 checks passed
koala73 added a commit that referenced this pull request Jul 5, 2026
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant