Time-windowed spending limiter with persistent storage.
| 123 | |
| 124 | |
| 125 | class SpendControl: |
| 126 | """Time-windowed spending limiter with persistent storage.""" |
| 127 | |
| 128 | def __init__( |
| 129 | self, |
| 130 | storage: SpendControlStorage | None = None, |
| 131 | now_fn: Any = None, |
| 132 | ) -> None: |
| 133 | self._storage = storage or FileSpendControlStorage() |
| 134 | self._now = now_fn or time.time |
| 135 | self._limits = SpendLimits() |
| 136 | self._history: list[SpendRecord] = [] |
| 137 | self._session_spent: float = 0.0 |
| 138 | self._session_calls: int = 0 |
| 139 | self._load() |
| 140 | |
| 141 | def set_limit(self, window: SpendWindow, amount: float) -> None: |
| 142 | if amount <= 0 or not isinstance(amount, (int, float)): |
| 143 | raise ValueError("Limit must be a positive number") |
| 144 | setattr(self._limits, window, float(amount)) |
| 145 | self._save() |
| 146 | |
| 147 | def clear_limit(self, window: SpendWindow) -> None: |
| 148 | setattr(self._limits, window, None) |
| 149 | self._save() |
| 150 | |
| 151 | @property |
| 152 | def limits(self) -> SpendLimits: |
| 153 | return self._limits |
| 154 | |
| 155 | def check(self, estimated_cost: float, additional_committed: float = 0.0) -> CheckResult: |
| 156 | """Check whether ``estimated_cost`` fits under every configured window. |
| 157 | |
| 158 | ``additional_committed`` covers in-flight reservations (estimated costs of |
| 159 | concurrent requests that passed ``check`` but have not yet been settled). |
| 160 | It is added to every windowed denominator but NOT to the per-request |
| 161 | comparison, which stays strictly about a single call. |
| 162 | """ |
| 163 | now = self._now() |
| 164 | |
| 165 | if self._limits.per_request is not None and estimated_cost > self._limits.per_request: |
| 166 | return CheckResult( |
| 167 | allowed=False, |
| 168 | blocked_by="per_request", |
| 169 | remaining=self._limits.per_request, |
| 170 | reason=f"Per-request limit: ${estimated_cost:.4f} > ${self._limits.per_request:.2f} max", |
| 171 | ) |
| 172 | |
| 173 | if self._limits.hourly is not None: |
| 174 | hourly_spent = self._window_total(now - HOUR_S, now) + additional_committed |
| 175 | remaining = self._limits.hourly - hourly_spent |
| 176 | if estimated_cost > remaining: |
| 177 | oldest = next((r for r in self._history if r.timestamp >= now - HOUR_S), None) |
| 178 | reset_in = int(oldest.timestamp + HOUR_S - now) if oldest else 0 |
| 179 | return CheckResult( |
| 180 | allowed=False, blocked_by="hourly", remaining=remaining, |
| 181 | reason=f"Hourly limit: ${hourly_spent + estimated_cost:.2f} > ${self._limits.hourly:.2f}", |
| 182 | reset_in_s=max(0, reset_in), |
no outgoing calls