Coverage for src/case_router/limits.py: 100%
56 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 18:59 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 18:59 +0000
1"""Per-tenant request limits.
3Each tenant gets its own token bucket sized from its requests-per-minute allowance, so one
4busy org cannot use up capacity that belongs to the others.
5"""
7from __future__ import annotations
9import threading
10import time
11from collections.abc import Callable
12from dataclasses import dataclass
14from .models import Tenant
16Clock = Callable[[], float]
19@dataclass(frozen=True, slots=True)
20class Acquisition:
21 allowed: bool
22 retry_after: float = 0.0
25class TokenBucket:
26 """Thread-safe token bucket.
28 Holds up to ``capacity`` tokens and refills continuously at ``refill_per_second``.
29 Every request spends one token; with none left, the caller learns how long to wait.
30 """
32 def __init__(self, capacity: int, refill_per_second: float, clock: Clock = time.monotonic):
33 if capacity < 1:
34 raise ValueError("capacity must be at least 1")
35 if refill_per_second <= 0:
36 raise ValueError("refill_per_second must be positive")
37 self.capacity = capacity
38 self.refill_per_second = refill_per_second
39 self._clock = clock
40 self._tokens = float(capacity)
41 self._updated = clock()
42 self._lock = threading.Lock()
44 def _refill(self) -> None:
45 now = self._clock()
46 if now <= self._updated:
47 return # no time has passed, or the clock stepped back: add nothing
48 elapsed = now - self._updated
49 self._tokens = min(float(self.capacity), self._tokens + elapsed * self.refill_per_second)
50 self._updated = now
52 def try_acquire(self) -> Acquisition:
53 with self._lock:
54 self._refill()
55 if self._tokens >= 1:
56 self._tokens -= 1
57 return Acquisition(allowed=True)
58 return Acquisition(
59 allowed=False, retry_after=(1 - self._tokens) / self.refill_per_second
60 )
62 @property
63 def available(self) -> float:
64 with self._lock:
65 self._refill()
66 return self._tokens
69class TenantLimiter:
70 """Keeps one bucket per tenant and rebuilds it if the tenant's allowance changes."""
72 def __init__(self, clock: Clock = time.monotonic) -> None:
73 self._clock = clock
74 self._buckets: dict[str, TokenBucket] = {}
75 self._lock = threading.Lock()
77 def bucket_for(self, tenant: Tenant) -> TokenBucket:
78 with self._lock:
79 bucket = self._buckets.get(tenant.id)
80 if bucket is None or bucket.capacity != tenant.requests_per_minute:
81 bucket = TokenBucket(
82 capacity=tenant.requests_per_minute,
83 refill_per_second=tenant.requests_per_minute / 60,
84 clock=self._clock,
85 )
86 self._buckets[tenant.id] = bucket
87 return bucket
89 def acquire(self, tenant: Tenant) -> Acquisition:
90 return self.bucket_for(tenant).try_acquire()