Make the allowance a ladder: free, signed in, sponsored - #167
Merged
Conversation
The directory stays open to everyone -- no key, no account, answered from the same data with no field withheld. What changes is rate. A free caller gets an hourly budget, signing in multiplies it by ten, and a sponsor's key lifts it to what the server can actually carry. Money buys throughput, never access, which is the shape apiguard.js argued for before any of this existed: a metered-but-open API can grow a paid tier by raising a number, where a gated one has already broken every agent reading this today. ## An hourly budget on top of the per-minute one crawlThrottle meters a 60-second window. That is a burst control -- it stops a caller taking the machine in one second, and it is sized against what a person does. It is not a sustained-rate control, and a crawler pacing itself at 119 requests a minute never trips it while taking 7,200 an hour. So each tier now carries both: a burst ceiling per minute and a budget per hour. A reader browsing hard is bursty and nowhere near the hourly figure; a crawler walking the directory is the reverse. Together they separate the two where either alone does not. An exhausted hourly budget is deliberately not an exhausted burst. It earns no strike and no escalating lock, and the caller is told how long until rollover. A burst is a caller misbehaving right now and the doubling penalty makes a runaway loop expensive; spending an hourly allowance is a pricing question, and answering it with an escalating punishment would treat a crawler that politely stays at its limit like one hammering the door. ## Signed in is no longer unmetered It used to skip metering entirely. That was defensible while a session was the only rung above anonymous, and it stops being defensible the moment signing in is a tier: an unmetered rung means the ladder tops out at "make a free account" and nothing above it can be worth paying for. Signed in is now a large budget rather than no budget. ## Deciding the tier without a database The proxy stays synchronous and keeps its lookups out of the request path. A session is cookie presence only, exactly as the throttle already decided it. A forged cookie buys the authenticated allowance and nothing else -- it reads no data an anonymous caller cannot already read. A key is answered from an in-memory cache and refreshed behind the request, so the first call from a newly-issued key is metered one rung down while the lookup lands, then settles. An unverified key is always metered *down*, never up, or anyone could mint sponsor throughput by inventing a token. Defaults are 600/hour free, 6,000 signed in, 120,000 sponsored, all overridable by env because they were chosen against one hour of measurement. The sponsor ceiling is finite on purpose: a runaway loop on a customer's key is still a runaway loop, and an unbounded tier means their bad deploy takes the directory with it. The 429 now says which rung the caller is on and what the next one costs. An agent reading that is exactly the caller who can act on it without a human. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SisjDW4F1zwTeySnUM6VbB
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Builds on #166. The directory stays open to everyone — no key, no account, same data, no field withheld. What changes is rate: money buys throughput, never access.
anonsessionsponsorAll five numbers are env-overridable (
TIER_FREE_HOURLY,TIER_AUTH_MULTIPLIER,TIER_SPONSOR_HOURLY,TIER_FREE_BURST,TIER_AUTH_BURST,TIER_SPONSOR_BURST) because they were chosen against roughly one hour of measurement.Why an hourly budget on top of the per-minute one
crawlThrottlemeters a 60-second window. That's a burst control — it stops a caller taking the machine in one second, and it's sized against what a person does. It is not a sustained-rate control: a crawler pacing itself at 119/min never trips it and still takes 7,200 requests an hour.Each tier now carries both. A reader browsing hard is bursty and nowhere near the hourly figure; a crawler walking the directory is the reverse. Together they separate the two where either alone doesn't.
An exhausted budget is not an exhausted burst. It earns no strike and no escalating lock — the caller is just told when it rolls over. A burst is misbehaviour happening now, and the doubling penalty makes a runaway loop expensive. Spending an hourly allowance is a pricing question, and answering it with escalating punishment would treat a crawler politely sitting at its limit like one hammering the door.
Signed in is no longer unmetered
This is the behavioural change to review most carefully. Signed-in callers previously skipped metering entirely. That was fine while a session was the only rung above anonymous; it stops being fine the moment signing in is a tier, because an unmetered rung means the ladder tops out at "make a free account" — which is free and unlimited — and nothing above it can ever be worth paying for.
So this is a tightening for signed-in users: 6,000/hour instead of unlimited. With 14 accounts and one active, the blast radius is small.
Deciding the tier without a database
The proxy stays synchronous and keeps lookups out of the request path.
The sponsor ceiling is finite on purpose. A runaway loop on a customer's key is still a runaway loop, and an unbounded tier means their bad deploy takes the directory down with it. 120,000/hour is ~33/sec sustained.
The 429 now says where to go
It carries
tier,hourlyLimit, and anupgradeline naming the next rung (/login, then an API key at/account), plusx-ratelimit-tierandx-ratelimit-limit-hourheaders. A limiter that only says "slow down" leaves a caller with nothing to do but retry, and the point of a ladder is that there's somewhere to go — an agent reading this is exactly the caller who can act on it without a human.Compatibility
attempt(identity, now)keeps its old behaviour exactly: the default allowance is the existing 120/min burst with no hourly budget. Only the proxy passes a real tier. That's what lets the 322 pre-existing tests pass untouched.Testing
apps/web/test/tiers.test.js; full suite 342 passing, 0 failures;pnpm buildclean.looksLikeApiKeyrejected them before the cache was ever consulted and the tests passed without exercising the key path at all — they'd have gone on passing if the sponsor tier were unreachable. Now pinned by "the sponsor tier is actually reachable with a verified key", using the realrsa_<8 hex>_<20+>shape.Interaction with apiguard
Unchanged and deliberately so.
apiguardstill applies each key's ownhourly_limit(default 5,000) on/api/feedsand/api/search. The tighter of the two wins, which is right: the key's own limit is the deal. Selling sponsorship means issuing a key with a raisedhourly_limit— already visible in the/accountUI.Not in this PR
Per-bucket cost weighting.
readerfetches and extracts a third-party page and can pay for a translation, so it's worth orders of magnitude more than a directory page, and a flat per-request ceiling prices them identically. #166's data showsreaderrunning near 1,000/hour. That's the next lever and it's a bigger semantic change (budgets stop being "queries" and become "units"), so it deserves its own review.🤖 Generated with Claude Code
https://claude.ai/code/session_01SisjDW4F1zwTeySnUM6VbB