agent-pages · fuzzy title search

One search request, five safety gates.

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.

scope
Only a valid search query consumes this budget. Missing, blank, too-short, and over-length queries stop before SQLite. Query parameters on /, theme routes, artifact routes, and APIs are outside this limiter.
free validation SQLite counters candidate cache scoring capacity request stops

The mental model

Three different resources are protected

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.

Gate 1

Input

Reject junk before it can spend anyone’s budget.

Gate 2

Identity bucket

Resolve the actual visitor behind trusted proxies.

Gate 3

Request budget

Consume burst and sustained limits atomically.

Gate 4

Reuse

A cache hit skips fuzzy scoring but still counts as a request.

Gate 5

CPU slot

A cache miss scores only when a process-wide slot is free.

The complete flow

The ten steps, with every branch

Follow the numbered spine. Red outcomes return immediately; green and blue outcomes continue downward.

1

Validate q without consuming budget

Decode the query, preserve a safe display copy, and normalize a separate match copy.

Missing / blank

Canonical full listing.

303 → /
Normalized length < 2

Show “enter at least 2 characters.”

200 · no scan
Raw or normalized > 64

Show an inline validation message.

400 · no budget
Valid: “kubrenetes”

Length 10; continue.

proceed
2

Resolve the client bucket

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.

3

Consume both windows in one short SQLite transaction

Defaults are 20 per minute and 200 per hour. Both provisional increments happen in one transaction, so the request either consumes both or neither.

4

If either counter fails, roll back both

Both have room

  1. Burst 8 → 9
  2. Hour 41 → 42
  3. Commit both
COMMIT · continue

Burst is full

  1. Burst stays 20 / 20
  2. Hourly counter untouched
  3. No cache or scoring work
ROLLBACK · 429

Hour is full

  1. Burst 8 → 9 provisionally
  2. Hour stays 200 / 200
  3. Rollback restores burst to 8
ROLLBACK BOTH · 429
Why one transaction matters: with two committed transactions, a request rejected by the hourly window would still waste one minute-window slot. Atomic rollback removes that accounting leak.
5

Fetch active candidates, fingerprint the corpus, check the cache

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.

6

Cache hit: render without acquiring a scoring slot

What is reused

Immutable ordered (page_id, score) values, total matched, and truncation state.

What stays fresh

Lifecycle and display metadata came from the current candidate fetch, then cached IDs are mapped onto it.

A cache hit still consumed burst and hourly budget. The cache saves CPU; it does not turn the endpoint into an unlimited request surface.
7

Cache miss: try to acquire a scoring slot without waiting

With SEARCH_CONCURRENCY=2, the process owns two scoring slots. Acquisition is non-blocking: a request either owns a slot now or degrades immediately.

slot A
available
slot B
scoring request #42
→ request acquires slot A
8

No slot: return an inline 503

slot A
busy
slot B
busy
→ no queue

Response: 503 Service Unavailable with Retry-After: 1. The page retains its search form and link back to the ledger.

The request budget remains consumed. The two counters committed earlier. A busy CPU is not a reason to refund a valid request, because repeated retries are themselves load.
9

Score inside 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.

10

Store an immutable result and render

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

Six requests, six different journeys

These examples show exactly which state changes survive each outcome.

A. Too short

GET /search?q=k

  1. Normalizes to one character.
  2. Stops during validation.
  3. No IP resolution, SQLite write, candidate fetch, cache read, or semaphore.
200 · guidance · counters unchanged

B. First valid search

GET /search?q=kubrenetes

  1. Valid query; visitor bucket resolved.
  2. Burst and hourly increments commit together.
  3. Active candidates fetched; cache miss.
  4. Slot acquired, scored, released in finally.
  5. Immutable result cached and rendered.
200 · counters +1 · cache populated

C. Same search again

GET /search?q=kubrenetes

  1. Validation and bucket resolution still run.
  2. Both counters increment and commit again.
  3. Fresh candidate fingerprint matches the cache key.
  4. No semaphore and no RapidFuzz scoring.
200 · counters +1 · scoring skipped

D. Burst exhausted

GET /search?q=migration · burst 20/20

  1. Valid query reaches the counter transaction.
  2. Burst conditional update refuses the increment.
  3. Transaction rolls back; hourly counter is untouched.
  4. No candidate fetch, cache read, or scoring.
429 · Retry-After until minute reset

E. CPU capacity full

GET /search?q=deployment · two slots busy

  1. Both request counters increment and commit.
  2. Candidate fetch finds a cache miss.
  3. Non-blocking semaphore acquisition fails.
  4. No work waits in memory and no scoring begins.
503 · Retry-After: 1 · counters remain +1

F. Scorer raises unexpectedly

GET /search?q=architecture

  1. Counters commit; a scoring slot is acquired.
  2. RapidFuzz wrapper raises before producing a result.
  3. finally releases the owned slot.
  4. No partial cache entry is stored.
500 · capacity fully restored

Outcome matrix

What each response consumed

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

One mechanism cannot replace another

Validation protects fairness

Malformed input cannot spend the shared IP budget or touch the database.

Fixed windows protect volume

A single client cannot sustain an unlimited request rate, even with cache hits.

Atomicity protects accounting

A rejected request never consumes just one of the two required budgets.

The cache protects repeated work

The same query over the same active corpus reuses scores without weakening request limits.

The semaphore protects CPU

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.

Privacy boundary: structured application events record limit kind, bucket hash, counts, latency, and cache status—but never explicitly record the query. Because search uses a GET URL, query text may still appear in browser history, copied links, and Uvicorn or Traefik access logs.