← back to Unclaimed Property Platform
auto-save: 2026-08-01T11:36:26 (2 files) — services/common/rate_limit.py services/search/service.py
3d2a81a25c2d64d6c6831fa936ae3b515cdf25c1 · 2026-08-01 11:36:29 -0700 · Steve Abrams
Files touched
A services/common/rate_limit.pyA services/search/service.py
Diff
commit 3d2a81a25c2d64d6c6831fa936ae3b515cdf25c1
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sat Aug 1 11:36:29 2026 -0700
auto-save: 2026-08-01T11:36:26 (2 files) — services/common/rate_limit.py services/search/service.py
---
services/common/rate_limit.py | 53 +++++++++++++++++++++++++++++++++++++++++++
services/search/service.py | 51 +++++++++++++++++++++++++++++++++++++++++
2 files changed, 104 insertions(+)
diff --git a/services/common/rate_limit.py b/services/common/rate_limit.py
new file mode 100644
index 0000000..dbe709c
--- /dev/null
+++ b/services/common/rate_limit.py
@@ -0,0 +1,53 @@
+"""In-memory sliding-window rate limiter (stdlib).
+
+Replaces the search API's `require_rate_limit_STUB` with a real control. This is the
+single-process reference; production keys the same algorithm off Redis (atomic INCR + TTL,
+or a sorted-set sliding window) so limits hold across many API instances.
+
+The clock is INJECTABLE so time-based behavior is tested deterministically (no sleeps).
+
+SECURITY: the limiter key MUST be derived from the TRUSTED-proxy client IP (+ optional
+session), never a raw client-supplied X-Forwarded-For header (that is spoofable and would
+let an attacker mint unlimited buckets). The caller is responsible for passing a trusted key.
+"""
+from __future__ import annotations
+
+import time
+from collections import defaultdict, deque
+from typing import Callable
+
+
+class SlidingWindowRateLimiter:
+ def __init__(self, max_requests: int, window_seconds: float,
+ clock: Callable[[], float] = time.monotonic) -> None:
+ if max_requests < 1 or window_seconds <= 0:
+ raise ValueError("max_requests>=1 and window_seconds>0 required")
+ self.max_requests = max_requests
+ self.window_seconds = window_seconds
+ self._clock = clock
+ self._hits: dict[str, deque[float]] = defaultdict(deque)
+
+ def allow(self, key: str) -> bool:
+ """Record a request for `key`; return False if it exceeds the window budget."""
+ now = self._clock()
+ cutoff = now - self.window_seconds
+ bucket = self._hits[key]
+ while bucket and bucket[0] <= cutoff:
+ bucket.popleft()
+ if len(bucket) >= self.max_requests:
+ return False
+ bucket.append(now)
+ return True
+
+ def retry_after(self, key: str) -> float:
+ """Seconds until the oldest in-window hit for `key` ages out (for a 429 header)."""
+ bucket = self._hits.get(key)
+ if not bucket:
+ return 0.0
+ return max(0.0, self.window_seconds - (self._clock() - bucket[0]))
+
+ def reset(self, key: str | None = None) -> None:
+ if key is None:
+ self._hits.clear()
+ else:
+ self._hits.pop(key, None)
diff --git a/services/search/service.py b/services/search/service.py
new file mode 100644
index 0000000..5ee4f4b
--- /dev/null
+++ b/services/search/service.py
@@ -0,0 +1,51 @@
+"""SearchService — framework-agnostic anonymous-search core (stdlib).
+
+Composes the anti-enumeration + privacy controls in ONE place so every transport (FastAPI,
+a stdlib http.server, tests) enforces them identically:
+
+ validate query -> rate-limit (per trusted client key) -> masked_search
+ -> assert_public_safe on every row -> masked projection out
+
+The authoritative property table is never queried directly by transports; they call this.
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from services.common.public_projection import assert_public_safe
+from services.common.rate_limit import SlidingWindowRateLimiter
+
+
+class EmptyQuery(ValueError):
+ """Query had no alphanumeric token — rejected before hitting the index."""
+
+
+class RateLimited(Exception):
+ def __init__(self, retry_after: float) -> None:
+ super().__init__(f"rate limited; retry after {retry_after:.1f}s")
+ self.retry_after = retry_after
+
+
+@dataclass
+class SearchService:
+ repository: object # provides masked_search(query, limit)
+ limiter: SlidingWindowRateLimiter
+ max_results: int = 20
+
+ def search(self, query: str, client_key: str, limit: int | None = None) -> list[dict]:
+ # 1) anti-enumeration: rate-limit BEFORE any work, keyed by trusted client id.
+ if not self.limiter.allow(client_key):
+ raise RateLimited(self.limiter.retry_after(client_key))
+
+ # 2) reject empty/punctuation-only queries (mass-enumeration primitive).
+ if not query or not query.strip():
+ raise EmptyQuery("search query must contain at least one alphanumeric token")
+
+ cap = min(limit or self.max_results, self.max_results)
+ try:
+ rows = self.repository.masked_search(query, limit=cap)
+ except ValueError as exc: # repository's own empty-query guard
+ raise EmptyQuery(str(exc)) from exc
+
+ # 3) fail closed: every row must be an allowlisted masked projection.
+ return [assert_public_safe(r) for r in rows]
← ef78cf7 docs: ledger Cycle-3 verification/reconciliation record; TK-
·
back to Unclaimed Property Platform
·
Cycle 4: runnable masked-search service + REAL rate limiter bdd1a4b →