Input
Reject junk before it can spend anyone’s budget.
agent-pages · fuzzy title search
This is the proposed request contract for GET /search?q=….
It separates request-rate protection, cache reuse, and CPU concurrency so
each mechanism has one clear job—and one clear failure response.
/, theme routes, artifact routes, and APIs
are outside this limiter.
The mental model
A request must pass five gates. Rate counters protect request volume; the cache avoids repeating known work; the semaphore protects the CPU. Passing one gate does not imply that the next gate has capacity.
Reject junk before it can spend anyone’s budget.
Resolve the actual visitor behind trusted proxies.
Consume burst and sustained limits atomically.
A cache hit skips fuzzy scoring but still counts as a request.
A cache miss scores only when a process-wide slot is free.
The complete flow
Follow the numbered spine. Red outcomes return immediately; green and blue outcomes continue downward.
q without consuming budgetDecode the query, preserve a safe display copy, and normalize a separate match copy.
Canonical full listing.
303 → /Show “enter at least 2 characters.”
200 · no scanShow an inline validation message.
400 · no budgetLength 10; continue.
proceed
The limiter needs a stable key. Use the socket peer directly unless
it belongs to TRUSTED_PROXY_CIDRS; for a trusted proxy,
walk X-Forwarded-For from the right until the first
untrusted hop.
The database key is a SHA-256 bucket such as
search-ip-min:<resolved-ip>; application logs use the
bucketed key, never the search text.
Defaults are 20 per minute and 200 per hour. Both provisional increments happen in one transaction, so the request either consumes both or neither.
Only active, unclaimed pages are candidates. Bodies and PIN hashes
are never fetched. The ordered (id, current_version) pairs
form a deterministic SHA-256 fingerprint.
Immutable ordered (page_id, score) values, total matched, and truncation state.
Lifecycle and display metadata came from the current candidate fetch, then cached IDs are mapped onto it.
With SEARCH_CONCURRENCY=2, the process owns two scoring
slots. Acquisition is non-blocking: a request either owns a slot now
or degrades immediately.
503
Response: 503 Service Unavailable with
Retry-After: 1. The page retains its search form and link
back to the ledger.
try/finally
If scoring raises, normal error handling may return a 500,
but the slot is still released. Without finally, one rare
exception would permanently reduce capacity from two slots to one.
Under a small cache lock, insert the tuple result into the 256-entry LRU. Release the lock, map IDs to current summaries, then render the visible count and relevance-ordered table.
Worked examples
These examples show exactly which state changes survive each outcome.
GET /search?q=k
GET /search?q=kubrenetes
GET /search?q=kubrenetes
GET /search?q=migration · burst 20/20
GET /search?q=deployment · two slots busy
GET /search?q=architecture
finally releases the owned slot.Outcome matrix
| Case | Status | Counter transaction | Candidate fetch | Scoring slot | Counter state |
|---|---|---|---|---|---|
| Missing / blank | 303 |
No | No | No | Unchanged |
| Short / over 64 | 200 / 400 |
No | No | No | Unchanged |
| Burst or sustained full | 429 |
Attempted, rolled back | No | No | Both unchanged |
| Cache hit | 200 |
Committed | Yes | No | Both +1 |
| Cache miss, slot busy | 503 |
Committed | Yes | Attempted, not acquired | Both +1 |
| Cache miss, scored | 200 |
Committed | Yes | Acquired + released | Both +1 |
| Scoring exception | 500 |
Committed | Yes | Released by finally | Both +1 |
Why each layer exists
Malformed input cannot spend the shared IP budget or touch the database.
A single client cannot sustain an unlimited request rate, even with cache hits.
A rejected request never consumes just one of the two required budgets.
The same query over the same active corpus reuses scores without weakening request limits.
Many different IPs cannot create unlimited simultaneous fuzzy-scoring work.
finally protects future capacity
Success, exception, or cancellation cannot leak a process-wide scoring slot.