[object Object]

← 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

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 →