diff --git a/app/api/v2_quota.py b/app/api/v2_quota.py index 71ce889..ebfc013 100644 --- a/app/api/v2_quota.py +++ b/app/api/v2_quota.py @@ -118,3 +118,45 @@ class QuotaTracker: def bytes_used_today(self, user: str) -> int: with self._lock: return self._ensure_bucket(user)["bytes"] + + +# Module-level singleton (process-local quota state per spec §3.8). FastAPI +# dispatches sync handlers via a thread pool, so two concurrent first-time +# requests can both observe `_quota_singleton is None` and each construct a +# separate tracker; the second assignment wins and the first reference leaks +# split-brain quota state. Guard with an init lock + double-check. +# +# Note: `_quota_singleton` and `_quota_init_lock` are intentionally +# module-private. Callers MUST go through `_build_quota_tracker()` so the +# singleton stays single. Re-exporting `_quota_singleton` from another +# module via `from app.api.v2_quota import _quota_singleton` would copy the +# initial-None binding at import time and never see subsequent updates — +# that's a footgun. The function re-export is safe (it always reads the +# live module-global). +_quota_init_lock = threading.Lock() +_quota_singleton: "QuotaTracker | None" = None + + +def _build_quota_tracker() -> QuotaTracker: + """Returns or constructs the process-local quota tracker (thread-safe). + + Shared across `/api/v2/scan` (the original caller) and `/api/query` + (issue #160 cost guardrail) so the per-user daily byte cap accumulates + across both BQ-touching paths. + """ + from app.instance_config import get_value + global _quota_singleton + if _quota_singleton is not None: + return _quota_singleton + with _quota_init_lock: + if _quota_singleton is None: + _quota_singleton = QuotaTracker( + max_concurrent_per_user=int( + get_value("api", "scan", "max_concurrent_per_user", default=5) or 5 + ), + max_daily_bytes_per_user=int( + get_value("api", "scan", "max_daily_bytes_per_user", default=53687091200) + or 53687091200 + ), + ) + return _quota_singleton diff --git a/app/api/v2_scan.py b/app/api/v2_scan.py index 0027a18..ff80b25 100644 --- a/app/api/v2_scan.py +++ b/app/api/v2_scan.py @@ -244,28 +244,15 @@ async def scan_estimate_endpoint( ) -# Module-level singleton (process-local quota state per spec §3.8). FastAPI -# dispatches sync handlers via a thread pool, so two concurrent first-time -# requests can both observe `_quota_singleton is None` and each construct a -# separate tracker; the second assignment wins and the first reference leaks -# split-brain quota state. Guard with an init lock + double-check. -import threading as _threading -_quota_init_lock = _threading.Lock() -_quota_singleton: QuotaTracker | None = None - - -def _build_quota_tracker() -> QuotaTracker: - """Returns or constructs the process-local quota tracker (thread-safe).""" - global _quota_singleton - if _quota_singleton is not None: - return _quota_singleton - with _quota_init_lock: - if _quota_singleton is None: - _quota_singleton = QuotaTracker( - max_concurrent_per_user=int(get_value("api", "scan", "max_concurrent_per_user", default=5) or 5), - max_daily_bytes_per_user=int(get_value("api", "scan", "max_daily_bytes_per_user", default=53687091200) or 53687091200), - ) - return _quota_singleton +# `_build_quota_tracker` lives in `app.api.v2_quota` so /api/query (issue #160) +# can share the same singleton without inverting the dep direction +# (api/query → api/v2/scan would couple a high-level endpoint to a sibling). +# Re-exported here so existing test sites that call +# `v2_scan._build_quota_tracker()` (7 in tests/test_v2_scan.py) keep working. +# Do NOT re-export `_quota_singleton` — `from X import var` copies the +# binding at import time, so a re-exported singleton would never see the +# initialized value (#160 review caveat). +from app.api.v2_quota import _build_quota_tracker # re-export def _max_result_bytes() -> int: