truthound-dashboard 1.3.1__py3-none-any.whl → 1.4.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- truthound_dashboard/api/alerts.py +258 -0
- truthound_dashboard/api/anomaly.py +1302 -0
- truthound_dashboard/api/cross_alerts.py +352 -0
- truthound_dashboard/api/deps.py +143 -0
- truthound_dashboard/api/drift_monitor.py +540 -0
- truthound_dashboard/api/lineage.py +1151 -0
- truthound_dashboard/api/maintenance.py +363 -0
- truthound_dashboard/api/middleware.py +373 -1
- truthound_dashboard/api/model_monitoring.py +805 -0
- truthound_dashboard/api/notifications_advanced.py +2452 -0
- truthound_dashboard/api/plugins.py +2096 -0
- truthound_dashboard/api/profile.py +211 -14
- truthound_dashboard/api/reports.py +853 -0
- truthound_dashboard/api/router.py +147 -0
- truthound_dashboard/api/rule_suggestions.py +310 -0
- truthound_dashboard/api/schema_evolution.py +231 -0
- truthound_dashboard/api/sources.py +47 -3
- truthound_dashboard/api/triggers.py +190 -0
- truthound_dashboard/api/validations.py +13 -0
- truthound_dashboard/api/validators.py +333 -4
- truthound_dashboard/api/versioning.py +309 -0
- truthound_dashboard/api/websocket.py +301 -0
- truthound_dashboard/core/__init__.py +27 -0
- truthound_dashboard/core/anomaly.py +1395 -0
- truthound_dashboard/core/anomaly_explainer.py +633 -0
- truthound_dashboard/core/cache.py +206 -0
- truthound_dashboard/core/cached_services.py +422 -0
- truthound_dashboard/core/charts.py +352 -0
- truthound_dashboard/core/connections.py +1069 -42
- truthound_dashboard/core/cross_alerts.py +837 -0
- truthound_dashboard/core/drift_monitor.py +1477 -0
- truthound_dashboard/core/drift_sampling.py +669 -0
- truthound_dashboard/core/i18n/__init__.py +42 -0
- truthound_dashboard/core/i18n/detector.py +173 -0
- truthound_dashboard/core/i18n/messages.py +564 -0
- truthound_dashboard/core/lineage.py +971 -0
- truthound_dashboard/core/maintenance.py +443 -5
- truthound_dashboard/core/model_monitoring.py +1043 -0
- truthound_dashboard/core/notifications/channels.py +1020 -1
- truthound_dashboard/core/notifications/deduplication/__init__.py +143 -0
- truthound_dashboard/core/notifications/deduplication/policies.py +274 -0
- truthound_dashboard/core/notifications/deduplication/service.py +400 -0
- truthound_dashboard/core/notifications/deduplication/stores.py +2365 -0
- truthound_dashboard/core/notifications/deduplication/strategies.py +422 -0
- truthound_dashboard/core/notifications/dispatcher.py +43 -0
- truthound_dashboard/core/notifications/escalation/__init__.py +149 -0
- truthound_dashboard/core/notifications/escalation/backends.py +1384 -0
- truthound_dashboard/core/notifications/escalation/engine.py +429 -0
- truthound_dashboard/core/notifications/escalation/models.py +336 -0
- truthound_dashboard/core/notifications/escalation/scheduler.py +1187 -0
- truthound_dashboard/core/notifications/escalation/state_machine.py +330 -0
- truthound_dashboard/core/notifications/escalation/stores.py +2896 -0
- truthound_dashboard/core/notifications/events.py +49 -0
- truthound_dashboard/core/notifications/metrics/__init__.py +115 -0
- truthound_dashboard/core/notifications/metrics/base.py +528 -0
- truthound_dashboard/core/notifications/metrics/collectors.py +583 -0
- truthound_dashboard/core/notifications/routing/__init__.py +169 -0
- truthound_dashboard/core/notifications/routing/combinators.py +184 -0
- truthound_dashboard/core/notifications/routing/config.py +375 -0
- truthound_dashboard/core/notifications/routing/config_parser.py +867 -0
- truthound_dashboard/core/notifications/routing/engine.py +382 -0
- truthound_dashboard/core/notifications/routing/expression_engine.py +1269 -0
- truthound_dashboard/core/notifications/routing/jinja2_engine.py +774 -0
- truthound_dashboard/core/notifications/routing/rules.py +625 -0
- truthound_dashboard/core/notifications/routing/validator.py +678 -0
- truthound_dashboard/core/notifications/service.py +2 -0
- truthound_dashboard/core/notifications/stats_aggregator.py +850 -0
- truthound_dashboard/core/notifications/throttling/__init__.py +83 -0
- truthound_dashboard/core/notifications/throttling/builder.py +311 -0
- truthound_dashboard/core/notifications/throttling/stores.py +1859 -0
- truthound_dashboard/core/notifications/throttling/throttlers.py +633 -0
- truthound_dashboard/core/openlineage.py +1028 -0
- truthound_dashboard/core/plugins/__init__.py +39 -0
- truthound_dashboard/core/plugins/docs/__init__.py +39 -0
- truthound_dashboard/core/plugins/docs/extractor.py +703 -0
- truthound_dashboard/core/plugins/docs/renderers.py +804 -0
- truthound_dashboard/core/plugins/hooks/__init__.py +63 -0
- truthound_dashboard/core/plugins/hooks/decorators.py +367 -0
- truthound_dashboard/core/plugins/hooks/manager.py +403 -0
- truthound_dashboard/core/plugins/hooks/protocols.py +265 -0
- truthound_dashboard/core/plugins/lifecycle/__init__.py +41 -0
- truthound_dashboard/core/plugins/lifecycle/hot_reload.py +584 -0
- truthound_dashboard/core/plugins/lifecycle/machine.py +419 -0
- truthound_dashboard/core/plugins/lifecycle/states.py +266 -0
- truthound_dashboard/core/plugins/loader.py +504 -0
- truthound_dashboard/core/plugins/registry.py +810 -0
- truthound_dashboard/core/plugins/reporter_executor.py +588 -0
- truthound_dashboard/core/plugins/sandbox/__init__.py +59 -0
- truthound_dashboard/core/plugins/sandbox/code_validator.py +243 -0
- truthound_dashboard/core/plugins/sandbox/engines.py +770 -0
- truthound_dashboard/core/plugins/sandbox/protocols.py +194 -0
- truthound_dashboard/core/plugins/sandbox.py +617 -0
- truthound_dashboard/core/plugins/security/__init__.py +68 -0
- truthound_dashboard/core/plugins/security/analyzer.py +535 -0
- truthound_dashboard/core/plugins/security/policies.py +311 -0
- truthound_dashboard/core/plugins/security/protocols.py +296 -0
- truthound_dashboard/core/plugins/security/signing.py +842 -0
- truthound_dashboard/core/plugins/security.py +446 -0
- truthound_dashboard/core/plugins/validator_executor.py +401 -0
- truthound_dashboard/core/plugins/versioning/__init__.py +51 -0
- truthound_dashboard/core/plugins/versioning/constraints.py +377 -0
- truthound_dashboard/core/plugins/versioning/dependencies.py +541 -0
- truthound_dashboard/core/plugins/versioning/semver.py +266 -0
- truthound_dashboard/core/profile_comparison.py +601 -0
- truthound_dashboard/core/report_history.py +570 -0
- truthound_dashboard/core/reporters/__init__.py +57 -0
- truthound_dashboard/core/reporters/base.py +296 -0
- truthound_dashboard/core/reporters/csv_reporter.py +155 -0
- truthound_dashboard/core/reporters/html_reporter.py +598 -0
- truthound_dashboard/core/reporters/i18n/__init__.py +65 -0
- truthound_dashboard/core/reporters/i18n/base.py +494 -0
- truthound_dashboard/core/reporters/i18n/catalogs.py +930 -0
- truthound_dashboard/core/reporters/json_reporter.py +160 -0
- truthound_dashboard/core/reporters/junit_reporter.py +233 -0
- truthound_dashboard/core/reporters/markdown_reporter.py +207 -0
- truthound_dashboard/core/reporters/pdf_reporter.py +209 -0
- truthound_dashboard/core/reporters/registry.py +272 -0
- truthound_dashboard/core/rule_generator.py +2088 -0
- truthound_dashboard/core/scheduler.py +822 -12
- truthound_dashboard/core/schema_evolution.py +858 -0
- truthound_dashboard/core/services.py +152 -9
- truthound_dashboard/core/statistics.py +718 -0
- truthound_dashboard/core/streaming_anomaly.py +883 -0
- truthound_dashboard/core/triggers/__init__.py +45 -0
- truthound_dashboard/core/triggers/base.py +226 -0
- truthound_dashboard/core/triggers/evaluators.py +609 -0
- truthound_dashboard/core/triggers/factory.py +363 -0
- truthound_dashboard/core/unified_alerts.py +870 -0
- truthound_dashboard/core/validation_limits.py +509 -0
- truthound_dashboard/core/versioning.py +709 -0
- truthound_dashboard/core/websocket/__init__.py +59 -0
- truthound_dashboard/core/websocket/manager.py +512 -0
- truthound_dashboard/core/websocket/messages.py +130 -0
- truthound_dashboard/db/__init__.py +30 -0
- truthound_dashboard/db/models.py +3375 -3
- truthound_dashboard/main.py +22 -0
- truthound_dashboard/schemas/__init__.py +396 -1
- truthound_dashboard/schemas/anomaly.py +1258 -0
- truthound_dashboard/schemas/base.py +4 -0
- truthound_dashboard/schemas/cross_alerts.py +334 -0
- truthound_dashboard/schemas/drift_monitor.py +890 -0
- truthound_dashboard/schemas/lineage.py +428 -0
- truthound_dashboard/schemas/maintenance.py +154 -0
- truthound_dashboard/schemas/model_monitoring.py +374 -0
- truthound_dashboard/schemas/notifications_advanced.py +1363 -0
- truthound_dashboard/schemas/openlineage.py +704 -0
- truthound_dashboard/schemas/plugins.py +1293 -0
- truthound_dashboard/schemas/profile.py +420 -34
- truthound_dashboard/schemas/profile_comparison.py +242 -0
- truthound_dashboard/schemas/reports.py +285 -0
- truthound_dashboard/schemas/rule_suggestion.py +434 -0
- truthound_dashboard/schemas/schema_evolution.py +164 -0
- truthound_dashboard/schemas/source.py +117 -2
- truthound_dashboard/schemas/triggers.py +511 -0
- truthound_dashboard/schemas/unified_alerts.py +223 -0
- truthound_dashboard/schemas/validation.py +25 -1
- truthound_dashboard/schemas/validators/__init__.py +11 -0
- truthound_dashboard/schemas/validators/base.py +151 -0
- truthound_dashboard/schemas/versioning.py +152 -0
- truthound_dashboard/static/index.html +2 -2
- {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.1.dist-info}/METADATA +147 -23
- truthound_dashboard-1.4.1.dist-info/RECORD +239 -0
- truthound_dashboard/static/assets/index-BZG20KuF.js +0 -586
- truthound_dashboard/static/assets/index-D_HyZ3pb.css +0 -1
- truthound_dashboard/static/assets/unmerged_dictionaries-CtpqQBm0.js +0 -1
- truthound_dashboard-1.3.1.dist-info/RECORD +0 -110
- {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.1.dist-info}/WHEEL +0 -0
- {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.1.dist-info}/entry_points.txt +0 -0
- {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
"""Throttler implementations.
|
|
2
|
+
|
|
3
|
+
This module provides 5 throttler types:
|
|
4
|
+
|
|
5
|
+
- TokenBucketThrottler: Smooth rate limiting with burst support
|
|
6
|
+
- FixedWindowThrottler: Simple counter per fixed window
|
|
7
|
+
- SlidingWindowThrottler: More accurate sliding window counter
|
|
8
|
+
- CompositeThrottler: Combines multiple throttlers
|
|
9
|
+
- NoOpThrottler: Pass-through for testing
|
|
10
|
+
|
|
11
|
+
Each throttler implements the allow() method to check if a
|
|
12
|
+
notification should be sent.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import time
|
|
18
|
+
from abc import ABC, abstractmethod
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import Any, ClassVar
|
|
21
|
+
|
|
22
|
+
from ...validation_limits import get_throttling_limits
|
|
23
|
+
from .stores import BaseThrottlingStore, InMemoryThrottlingStore, ThrottlingEntry
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class ThrottleResult:
|
|
28
|
+
"""Result of a throttle check.
|
|
29
|
+
|
|
30
|
+
Attributes:
|
|
31
|
+
allowed: Whether the request is allowed.
|
|
32
|
+
remaining: Remaining requests in current period.
|
|
33
|
+
limit: Total limit for the period.
|
|
34
|
+
retry_after: Seconds until next allowed request (if not allowed).
|
|
35
|
+
throttler_type: Which throttler made the decision.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
allowed: bool
|
|
39
|
+
remaining: int = 0
|
|
40
|
+
limit: int = 0
|
|
41
|
+
retry_after: float = 0.0
|
|
42
|
+
throttler_type: str = "unknown"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ThrottlerRegistry:
|
|
46
|
+
"""Registry for throttler types.
|
|
47
|
+
|
|
48
|
+
Provides plugin architecture for custom throttler implementations.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
_throttlers: ClassVar[dict[str, type["BaseThrottler"]]] = {}
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def register(cls, throttler_type: str):
|
|
55
|
+
"""Decorator to register a throttler type."""
|
|
56
|
+
|
|
57
|
+
def decorator(throttler_class: type["BaseThrottler"]) -> type["BaseThrottler"]:
|
|
58
|
+
throttler_class.throttler_type = throttler_type
|
|
59
|
+
cls._throttlers[throttler_type] = throttler_class
|
|
60
|
+
return throttler_class
|
|
61
|
+
|
|
62
|
+
return decorator
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def get(cls, throttler_type: str) -> type["BaseThrottler"] | None:
|
|
66
|
+
"""Get a registered throttler class by type."""
|
|
67
|
+
return cls._throttlers.get(throttler_type)
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def list_types(cls) -> list[str]:
|
|
71
|
+
"""Get list of registered throttler types."""
|
|
72
|
+
return list(cls._throttlers.keys())
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class BaseThrottler(ABC):
|
|
76
|
+
"""Abstract base class for throttlers.
|
|
77
|
+
|
|
78
|
+
All throttlers must implement the allow() method to
|
|
79
|
+
determine if a request should be permitted.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
throttler_type: ClassVar[str] = "base"
|
|
83
|
+
|
|
84
|
+
@abstractmethod
|
|
85
|
+
def allow(self, key: str) -> ThrottleResult:
|
|
86
|
+
"""Check if a request is allowed.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
key: Unique key for rate limiting (e.g., channel ID).
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
ThrottleResult indicating if allowed.
|
|
93
|
+
"""
|
|
94
|
+
...
|
|
95
|
+
|
|
96
|
+
@abstractmethod
|
|
97
|
+
def reset(self, key: str) -> None:
|
|
98
|
+
"""Reset throttling state for a key.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
key: Key to reset.
|
|
102
|
+
"""
|
|
103
|
+
...
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@ThrottlerRegistry.register("token_bucket")
|
|
107
|
+
class TokenBucketThrottler(BaseThrottler):
|
|
108
|
+
"""Token bucket throttler with validation.
|
|
109
|
+
|
|
110
|
+
Implements the token bucket algorithm for smooth rate limiting
|
|
111
|
+
with support for bursts.
|
|
112
|
+
|
|
113
|
+
Tokens are added at a constant rate up to a maximum capacity.
|
|
114
|
+
Each request consumes one token. Requests are allowed as long
|
|
115
|
+
as tokens are available.
|
|
116
|
+
|
|
117
|
+
Validation:
|
|
118
|
+
- capacity must be between 1 and 100000 (configurable).
|
|
119
|
+
- refill_rate must be between 0.001 and 10000 (reasonable range).
|
|
120
|
+
|
|
121
|
+
Attributes:
|
|
122
|
+
capacity: Maximum tokens (burst capacity).
|
|
123
|
+
refill_rate: Tokens added per second.
|
|
124
|
+
store: Storage backend.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
def __init__(
|
|
128
|
+
self,
|
|
129
|
+
capacity: float = 10.0,
|
|
130
|
+
refill_rate: float = 1.0,
|
|
131
|
+
store: BaseThrottlingStore | None = None,
|
|
132
|
+
) -> None:
|
|
133
|
+
"""Initialize token bucket throttler with validation.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
capacity: Maximum token capacity.
|
|
137
|
+
refill_rate: Tokens refilled per second.
|
|
138
|
+
store: Storage backend.
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
ValueError: If capacity or refill_rate are invalid.
|
|
142
|
+
"""
|
|
143
|
+
limits = get_throttling_limits()
|
|
144
|
+
|
|
145
|
+
# Validate capacity
|
|
146
|
+
if capacity < limits.limit_min:
|
|
147
|
+
raise ValueError(
|
|
148
|
+
f"capacity must be at least {limits.limit_min}, got {capacity}"
|
|
149
|
+
)
|
|
150
|
+
if capacity > limits.limit_max:
|
|
151
|
+
raise ValueError(
|
|
152
|
+
f"capacity must not exceed {limits.limit_max}, got {capacity}"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# Validate refill_rate
|
|
156
|
+
if refill_rate < 0.001:
|
|
157
|
+
raise ValueError(
|
|
158
|
+
f"refill_rate must be at least 0.001, got {refill_rate}"
|
|
159
|
+
)
|
|
160
|
+
if refill_rate > 10000:
|
|
161
|
+
raise ValueError(
|
|
162
|
+
f"refill_rate must not exceed 10000, got {refill_rate}"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
self.capacity = capacity
|
|
166
|
+
self.refill_rate = refill_rate
|
|
167
|
+
self.store = store or InMemoryThrottlingStore()
|
|
168
|
+
|
|
169
|
+
def allow(self, key: str) -> ThrottleResult:
|
|
170
|
+
"""Check if request is allowed using token bucket."""
|
|
171
|
+
now = time.time()
|
|
172
|
+
entry = self.store.get(key)
|
|
173
|
+
|
|
174
|
+
if entry is None:
|
|
175
|
+
# Initialize with full bucket
|
|
176
|
+
entry = ThrottlingEntry(
|
|
177
|
+
key=key,
|
|
178
|
+
tokens=self.capacity - 1, # Consume one for this request
|
|
179
|
+
last_refill=now,
|
|
180
|
+
)
|
|
181
|
+
self.store.set(entry)
|
|
182
|
+
return ThrottleResult(
|
|
183
|
+
allowed=True,
|
|
184
|
+
remaining=int(entry.tokens),
|
|
185
|
+
limit=int(self.capacity),
|
|
186
|
+
throttler_type=self.throttler_type,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# Calculate token refill
|
|
190
|
+
elapsed = now - entry.last_refill
|
|
191
|
+
new_tokens = min(
|
|
192
|
+
self.capacity,
|
|
193
|
+
entry.tokens + elapsed * self.refill_rate,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
if new_tokens < 1:
|
|
197
|
+
# Not enough tokens
|
|
198
|
+
wait_time = (1 - new_tokens) / self.refill_rate
|
|
199
|
+
return ThrottleResult(
|
|
200
|
+
allowed=False,
|
|
201
|
+
remaining=0,
|
|
202
|
+
limit=int(self.capacity),
|
|
203
|
+
retry_after=wait_time,
|
|
204
|
+
throttler_type=self.throttler_type,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# Consume token
|
|
208
|
+
entry.tokens = new_tokens - 1
|
|
209
|
+
entry.last_refill = now
|
|
210
|
+
self.store.set(entry)
|
|
211
|
+
|
|
212
|
+
return ThrottleResult(
|
|
213
|
+
allowed=True,
|
|
214
|
+
remaining=int(entry.tokens),
|
|
215
|
+
limit=int(self.capacity),
|
|
216
|
+
throttler_type=self.throttler_type,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def reset(self, key: str) -> None:
|
|
220
|
+
"""Reset to full bucket."""
|
|
221
|
+
entry = ThrottlingEntry(
|
|
222
|
+
key=key,
|
|
223
|
+
tokens=self.capacity,
|
|
224
|
+
last_refill=time.time(),
|
|
225
|
+
)
|
|
226
|
+
self.store.set(entry)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@ThrottlerRegistry.register("fixed_window")
|
|
230
|
+
class FixedWindowThrottler(BaseThrottler):
|
|
231
|
+
"""Fixed window throttler with validation.
|
|
232
|
+
|
|
233
|
+
Simple counter-based rate limiting with fixed time windows.
|
|
234
|
+
All requests within a window share the same counter.
|
|
235
|
+
|
|
236
|
+
Validation:
|
|
237
|
+
- limit must be between 1 and 100000 (configurable).
|
|
238
|
+
- window_seconds must be between 1 and 86400 (configurable).
|
|
239
|
+
|
|
240
|
+
Attributes:
|
|
241
|
+
limit: Maximum requests per window.
|
|
242
|
+
window_seconds: Window duration in seconds.
|
|
243
|
+
store: Storage backend.
|
|
244
|
+
"""
|
|
245
|
+
|
|
246
|
+
def __init__(
|
|
247
|
+
self,
|
|
248
|
+
limit: int = 10,
|
|
249
|
+
window_seconds: int = 60,
|
|
250
|
+
store: BaseThrottlingStore | None = None,
|
|
251
|
+
) -> None:
|
|
252
|
+
"""Initialize fixed window throttler with validation.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
limit: Maximum requests per window.
|
|
256
|
+
window_seconds: Window duration.
|
|
257
|
+
store: Storage backend.
|
|
258
|
+
|
|
259
|
+
Raises:
|
|
260
|
+
ValueError: If limit or window_seconds are invalid.
|
|
261
|
+
"""
|
|
262
|
+
limits = get_throttling_limits()
|
|
263
|
+
|
|
264
|
+
# Validate limit
|
|
265
|
+
valid, error = limits.validate_rate_limit(limit, "limit")
|
|
266
|
+
if not valid:
|
|
267
|
+
raise ValueError(error)
|
|
268
|
+
|
|
269
|
+
# Validate window_seconds
|
|
270
|
+
valid, error = limits.validate_window_seconds(window_seconds)
|
|
271
|
+
if not valid:
|
|
272
|
+
raise ValueError(error)
|
|
273
|
+
|
|
274
|
+
self.limit = limit
|
|
275
|
+
self.window_seconds = window_seconds
|
|
276
|
+
self.store = store or InMemoryThrottlingStore()
|
|
277
|
+
|
|
278
|
+
def allow(self, key: str) -> ThrottleResult:
|
|
279
|
+
"""Check if request is allowed using fixed window."""
|
|
280
|
+
now = time.time()
|
|
281
|
+
window_start = int(now // self.window_seconds) * self.window_seconds
|
|
282
|
+
|
|
283
|
+
# Increment and get count
|
|
284
|
+
count = self.store.increment(key, window_start)
|
|
285
|
+
|
|
286
|
+
if count > self.limit:
|
|
287
|
+
# Over limit
|
|
288
|
+
window_end = window_start + self.window_seconds
|
|
289
|
+
retry_after = window_end - now
|
|
290
|
+
return ThrottleResult(
|
|
291
|
+
allowed=False,
|
|
292
|
+
remaining=0,
|
|
293
|
+
limit=self.limit,
|
|
294
|
+
retry_after=retry_after,
|
|
295
|
+
throttler_type=self.throttler_type,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
return ThrottleResult(
|
|
299
|
+
allowed=True,
|
|
300
|
+
remaining=self.limit - count,
|
|
301
|
+
limit=self.limit,
|
|
302
|
+
throttler_type=self.throttler_type,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
def reset(self, key: str) -> None:
|
|
306
|
+
"""Reset counter for key."""
|
|
307
|
+
now = time.time()
|
|
308
|
+
window_start = int(now // self.window_seconds) * self.window_seconds
|
|
309
|
+
entry = ThrottlingEntry(key=key, count=0, window_start=window_start)
|
|
310
|
+
self.store.set(entry)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@ThrottlerRegistry.register("sliding_window")
|
|
314
|
+
class SlidingWindowThrottler(BaseThrottler):
|
|
315
|
+
"""Sliding window throttler with validation.
|
|
316
|
+
|
|
317
|
+
More accurate than fixed window by interpolating between
|
|
318
|
+
the current and previous window.
|
|
319
|
+
|
|
320
|
+
Uses weighted average of current and previous window counts
|
|
321
|
+
to approximate a true sliding window.
|
|
322
|
+
|
|
323
|
+
Validation:
|
|
324
|
+
- limit must be between 1 and 100000 (configurable).
|
|
325
|
+
- window_seconds must be between 1 and 86400 (configurable).
|
|
326
|
+
|
|
327
|
+
Attributes:
|
|
328
|
+
limit: Maximum requests per window.
|
|
329
|
+
window_seconds: Window duration in seconds.
|
|
330
|
+
store: Storage backend.
|
|
331
|
+
"""
|
|
332
|
+
|
|
333
|
+
def __init__(
|
|
334
|
+
self,
|
|
335
|
+
limit: int = 10,
|
|
336
|
+
window_seconds: int = 60,
|
|
337
|
+
store: BaseThrottlingStore | None = None,
|
|
338
|
+
) -> None:
|
|
339
|
+
"""Initialize sliding window throttler with validation.
|
|
340
|
+
|
|
341
|
+
Args:
|
|
342
|
+
limit: Maximum requests per window.
|
|
343
|
+
window_seconds: Window duration.
|
|
344
|
+
store: Storage backend.
|
|
345
|
+
|
|
346
|
+
Raises:
|
|
347
|
+
ValueError: If limit or window_seconds are invalid.
|
|
348
|
+
"""
|
|
349
|
+
limits = get_throttling_limits()
|
|
350
|
+
|
|
351
|
+
# Validate limit
|
|
352
|
+
valid, error = limits.validate_rate_limit(limit, "limit")
|
|
353
|
+
if not valid:
|
|
354
|
+
raise ValueError(error)
|
|
355
|
+
|
|
356
|
+
# Validate window_seconds
|
|
357
|
+
valid, error = limits.validate_window_seconds(window_seconds)
|
|
358
|
+
if not valid:
|
|
359
|
+
raise ValueError(error)
|
|
360
|
+
|
|
361
|
+
self.limit = limit
|
|
362
|
+
self.window_seconds = window_seconds
|
|
363
|
+
self.store = store or InMemoryThrottlingStore()
|
|
364
|
+
|
|
365
|
+
def allow(self, key: str) -> ThrottleResult:
|
|
366
|
+
"""Check if request is allowed using sliding window."""
|
|
367
|
+
now = time.time()
|
|
368
|
+
current_window = int(now // self.window_seconds) * self.window_seconds
|
|
369
|
+
prev_window = current_window - self.window_seconds
|
|
370
|
+
|
|
371
|
+
# Get current and previous window entries
|
|
372
|
+
current_entry = self.store.get(f"{key}:{current_window}")
|
|
373
|
+
prev_entry = self.store.get(f"{key}:{prev_window}")
|
|
374
|
+
|
|
375
|
+
current_count = current_entry.count if current_entry else 0
|
|
376
|
+
prev_count = prev_entry.count if prev_entry else 0
|
|
377
|
+
|
|
378
|
+
# Calculate weighted count
|
|
379
|
+
elapsed_in_window = now - current_window
|
|
380
|
+
prev_weight = 1 - (elapsed_in_window / self.window_seconds)
|
|
381
|
+
weighted_count = current_count + (prev_count * prev_weight)
|
|
382
|
+
|
|
383
|
+
if weighted_count >= self.limit:
|
|
384
|
+
# Over limit - estimate retry time
|
|
385
|
+
retry_after = self.window_seconds - elapsed_in_window
|
|
386
|
+
return ThrottleResult(
|
|
387
|
+
allowed=False,
|
|
388
|
+
remaining=0,
|
|
389
|
+
limit=self.limit,
|
|
390
|
+
retry_after=retry_after,
|
|
391
|
+
throttler_type=self.throttler_type,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
# Increment current window
|
|
395
|
+
self.store.increment(f"{key}:{current_window}", current_window)
|
|
396
|
+
|
|
397
|
+
remaining = max(0, int(self.limit - weighted_count - 1))
|
|
398
|
+
return ThrottleResult(
|
|
399
|
+
allowed=True,
|
|
400
|
+
remaining=remaining,
|
|
401
|
+
limit=self.limit,
|
|
402
|
+
throttler_type=self.throttler_type,
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
def reset(self, key: str) -> None:
|
|
406
|
+
"""Reset counters for key."""
|
|
407
|
+
now = time.time()
|
|
408
|
+
current_window = int(now // self.window_seconds) * self.window_seconds
|
|
409
|
+
prev_window = current_window - self.window_seconds
|
|
410
|
+
|
|
411
|
+
entry = ThrottlingEntry(key=f"{key}:{current_window}", count=0, window_start=current_window)
|
|
412
|
+
self.store.set(entry)
|
|
413
|
+
entry = ThrottlingEntry(key=f"{key}:{prev_window}", count=0, window_start=prev_window)
|
|
414
|
+
self.store.set(entry)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
@ThrottlerRegistry.register("composite")
|
|
418
|
+
class CompositeThrottler(BaseThrottler):
|
|
419
|
+
"""Composite throttler combining multiple throttlers.
|
|
420
|
+
|
|
421
|
+
Checks all configured throttlers and only allows if ALL
|
|
422
|
+
throttlers permit the request.
|
|
423
|
+
|
|
424
|
+
Useful for implementing multi-level rate limits
|
|
425
|
+
(e.g., per-minute AND per-hour limits).
|
|
426
|
+
|
|
427
|
+
Attributes:
|
|
428
|
+
throttlers: List of throttlers to check.
|
|
429
|
+
"""
|
|
430
|
+
|
|
431
|
+
def __init__(self, throttlers: list[BaseThrottler] | None = None) -> None:
|
|
432
|
+
"""Initialize composite throttler.
|
|
433
|
+
|
|
434
|
+
Args:
|
|
435
|
+
throttlers: List of throttlers to combine.
|
|
436
|
+
"""
|
|
437
|
+
self.throttlers = throttlers or []
|
|
438
|
+
|
|
439
|
+
def add(self, throttler: BaseThrottler) -> "CompositeThrottler":
|
|
440
|
+
"""Add a throttler to the composite.
|
|
441
|
+
|
|
442
|
+
Args:
|
|
443
|
+
throttler: Throttler to add.
|
|
444
|
+
|
|
445
|
+
Returns:
|
|
446
|
+
Self for chaining.
|
|
447
|
+
"""
|
|
448
|
+
self.throttlers.append(throttler)
|
|
449
|
+
return self
|
|
450
|
+
|
|
451
|
+
def allow(self, key: str) -> ThrottleResult:
|
|
452
|
+
"""Check if ALL throttlers allow the request."""
|
|
453
|
+
if not self.throttlers:
|
|
454
|
+
return ThrottleResult(
|
|
455
|
+
allowed=True,
|
|
456
|
+
throttler_type=self.throttler_type,
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
max_retry = 0.0
|
|
460
|
+
min_remaining = float("inf")
|
|
461
|
+
total_limit = 0
|
|
462
|
+
|
|
463
|
+
for throttler in self.throttlers:
|
|
464
|
+
result = throttler.allow(key)
|
|
465
|
+
|
|
466
|
+
if not result.allowed:
|
|
467
|
+
# Denied - return immediately with max retry time
|
|
468
|
+
if result.retry_after > max_retry:
|
|
469
|
+
max_retry = result.retry_after
|
|
470
|
+
denied_result = result
|
|
471
|
+
|
|
472
|
+
return ThrottleResult(
|
|
473
|
+
allowed=False,
|
|
474
|
+
remaining=0,
|
|
475
|
+
limit=result.limit,
|
|
476
|
+
retry_after=max_retry,
|
|
477
|
+
throttler_type=f"composite:{result.throttler_type}",
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
# Track minimums
|
|
481
|
+
if result.remaining < min_remaining:
|
|
482
|
+
min_remaining = result.remaining
|
|
483
|
+
total_limit = max(total_limit, result.limit)
|
|
484
|
+
|
|
485
|
+
return ThrottleResult(
|
|
486
|
+
allowed=True,
|
|
487
|
+
remaining=int(min_remaining) if min_remaining != float("inf") else 0,
|
|
488
|
+
limit=total_limit,
|
|
489
|
+
throttler_type=self.throttler_type,
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
def reset(self, key: str) -> None:
|
|
493
|
+
"""Reset all throttlers."""
|
|
494
|
+
for throttler in self.throttlers:
|
|
495
|
+
throttler.reset(key)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
@ThrottlerRegistry.register("noop")
|
|
499
|
+
class NoOpThrottler(BaseThrottler):
|
|
500
|
+
"""No-operation throttler.
|
|
501
|
+
|
|
502
|
+
Always allows requests. Useful for testing or
|
|
503
|
+
disabling throttling without code changes.
|
|
504
|
+
"""
|
|
505
|
+
|
|
506
|
+
def allow(self, key: str) -> ThrottleResult:
|
|
507
|
+
"""Always allow."""
|
|
508
|
+
return ThrottleResult(
|
|
509
|
+
allowed=True,
|
|
510
|
+
remaining=999999,
|
|
511
|
+
limit=999999,
|
|
512
|
+
throttler_type=self.throttler_type,
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
def reset(self, key: str) -> None:
|
|
516
|
+
"""No-op."""
|
|
517
|
+
pass
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
class NotificationThrottler:
|
|
521
|
+
"""Main throttling service for notifications.
|
|
522
|
+
|
|
523
|
+
Provides a high-level API for throttling notifications
|
|
524
|
+
with support for global and per-channel limits.
|
|
525
|
+
|
|
526
|
+
Example:
|
|
527
|
+
throttler = NotificationThrottler(
|
|
528
|
+
global_throttler=FixedWindowThrottler(limit=100, window_seconds=3600),
|
|
529
|
+
channel_throttlers={
|
|
530
|
+
"slack": TokenBucketThrottler(capacity=10, refill_rate=1),
|
|
531
|
+
},
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
result = throttler.allow("slack-channel-1")
|
|
535
|
+
if result.allowed:
|
|
536
|
+
send_notification()
|
|
537
|
+
"""
|
|
538
|
+
|
|
539
|
+
def __init__(
|
|
540
|
+
self,
|
|
541
|
+
global_throttler: BaseThrottler | None = None,
|
|
542
|
+
channel_throttlers: dict[str, BaseThrottler] | None = None,
|
|
543
|
+
default_throttler: BaseThrottler | None = None,
|
|
544
|
+
) -> None:
|
|
545
|
+
"""Initialize notification throttler.
|
|
546
|
+
|
|
547
|
+
Args:
|
|
548
|
+
global_throttler: Optional global rate limiter.
|
|
549
|
+
channel_throttlers: Per-channel type throttlers.
|
|
550
|
+
default_throttler: Default for channels without specific throttler.
|
|
551
|
+
"""
|
|
552
|
+
self.global_throttler = global_throttler
|
|
553
|
+
self.channel_throttlers = channel_throttlers or {}
|
|
554
|
+
self.default_throttler = default_throttler
|
|
555
|
+
|
|
556
|
+
def allow(
|
|
557
|
+
self,
|
|
558
|
+
channel_id: str,
|
|
559
|
+
channel_type: str | None = None,
|
|
560
|
+
) -> ThrottleResult:
|
|
561
|
+
"""Check if notification to channel is allowed.
|
|
562
|
+
|
|
563
|
+
Args:
|
|
564
|
+
channel_id: Unique channel identifier.
|
|
565
|
+
channel_type: Channel type (e.g., 'slack', 'email').
|
|
566
|
+
|
|
567
|
+
Returns:
|
|
568
|
+
ThrottleResult indicating if allowed.
|
|
569
|
+
"""
|
|
570
|
+
# Check global throttler
|
|
571
|
+
if self.global_throttler:
|
|
572
|
+
result = self.global_throttler.allow("global")
|
|
573
|
+
if not result.allowed:
|
|
574
|
+
return ThrottleResult(
|
|
575
|
+
allowed=False,
|
|
576
|
+
remaining=result.remaining,
|
|
577
|
+
limit=result.limit,
|
|
578
|
+
retry_after=result.retry_after,
|
|
579
|
+
throttler_type=f"global:{result.throttler_type}",
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
# Get channel-specific throttler
|
|
583
|
+
throttler = None
|
|
584
|
+
if channel_type and channel_type in self.channel_throttlers:
|
|
585
|
+
throttler = self.channel_throttlers[channel_type]
|
|
586
|
+
elif self.default_throttler:
|
|
587
|
+
throttler = self.default_throttler
|
|
588
|
+
|
|
589
|
+
# Check channel throttler
|
|
590
|
+
if throttler:
|
|
591
|
+
result = throttler.allow(channel_id)
|
|
592
|
+
if not result.allowed:
|
|
593
|
+
return ThrottleResult(
|
|
594
|
+
allowed=False,
|
|
595
|
+
remaining=result.remaining,
|
|
596
|
+
limit=result.limit,
|
|
597
|
+
retry_after=result.retry_after,
|
|
598
|
+
throttler_type=f"channel:{result.throttler_type}",
|
|
599
|
+
)
|
|
600
|
+
return result
|
|
601
|
+
|
|
602
|
+
# No throttling configured
|
|
603
|
+
return ThrottleResult(
|
|
604
|
+
allowed=True,
|
|
605
|
+
remaining=999999,
|
|
606
|
+
limit=999999,
|
|
607
|
+
throttler_type="none",
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
def set_channel_throttler(
|
|
611
|
+
self,
|
|
612
|
+
channel_type: str,
|
|
613
|
+
throttler: BaseThrottler,
|
|
614
|
+
) -> None:
|
|
615
|
+
"""Set throttler for a channel type.
|
|
616
|
+
|
|
617
|
+
Args:
|
|
618
|
+
channel_type: Channel type (e.g., 'slack').
|
|
619
|
+
throttler: Throttler to use.
|
|
620
|
+
"""
|
|
621
|
+
self.channel_throttlers[channel_type] = throttler
|
|
622
|
+
|
|
623
|
+
def get_stats(self) -> dict[str, Any]:
|
|
624
|
+
"""Get throttling statistics.
|
|
625
|
+
|
|
626
|
+
Returns:
|
|
627
|
+
Dictionary with stats.
|
|
628
|
+
"""
|
|
629
|
+
return {
|
|
630
|
+
"has_global": self.global_throttler is not None,
|
|
631
|
+
"channel_types": list(self.channel_throttlers.keys()),
|
|
632
|
+
"has_default": self.default_throttler is not None,
|
|
633
|
+
}
|