truthound-dashboard 1.3.0__py3-none-any.whl → 1.4.0__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.
Files changed (169) hide show
  1. truthound_dashboard/api/alerts.py +258 -0
  2. truthound_dashboard/api/anomaly.py +1302 -0
  3. truthound_dashboard/api/cross_alerts.py +352 -0
  4. truthound_dashboard/api/deps.py +143 -0
  5. truthound_dashboard/api/drift_monitor.py +540 -0
  6. truthound_dashboard/api/lineage.py +1151 -0
  7. truthound_dashboard/api/maintenance.py +363 -0
  8. truthound_dashboard/api/middleware.py +373 -1
  9. truthound_dashboard/api/model_monitoring.py +805 -0
  10. truthound_dashboard/api/notifications_advanced.py +2452 -0
  11. truthound_dashboard/api/plugins.py +2096 -0
  12. truthound_dashboard/api/profile.py +211 -14
  13. truthound_dashboard/api/reports.py +853 -0
  14. truthound_dashboard/api/router.py +147 -0
  15. truthound_dashboard/api/rule_suggestions.py +310 -0
  16. truthound_dashboard/api/schema_evolution.py +231 -0
  17. truthound_dashboard/api/sources.py +47 -3
  18. truthound_dashboard/api/triggers.py +190 -0
  19. truthound_dashboard/api/validations.py +13 -0
  20. truthound_dashboard/api/validators.py +333 -4
  21. truthound_dashboard/api/versioning.py +309 -0
  22. truthound_dashboard/api/websocket.py +301 -0
  23. truthound_dashboard/core/__init__.py +27 -0
  24. truthound_dashboard/core/anomaly.py +1395 -0
  25. truthound_dashboard/core/anomaly_explainer.py +633 -0
  26. truthound_dashboard/core/cache.py +206 -0
  27. truthound_dashboard/core/cached_services.py +422 -0
  28. truthound_dashboard/core/charts.py +352 -0
  29. truthound_dashboard/core/connections.py +1069 -42
  30. truthound_dashboard/core/cross_alerts.py +837 -0
  31. truthound_dashboard/core/drift_monitor.py +1477 -0
  32. truthound_dashboard/core/drift_sampling.py +669 -0
  33. truthound_dashboard/core/i18n/__init__.py +42 -0
  34. truthound_dashboard/core/i18n/detector.py +173 -0
  35. truthound_dashboard/core/i18n/messages.py +564 -0
  36. truthound_dashboard/core/lineage.py +971 -0
  37. truthound_dashboard/core/maintenance.py +443 -5
  38. truthound_dashboard/core/model_monitoring.py +1043 -0
  39. truthound_dashboard/core/notifications/channels.py +1020 -1
  40. truthound_dashboard/core/notifications/deduplication/__init__.py +143 -0
  41. truthound_dashboard/core/notifications/deduplication/policies.py +274 -0
  42. truthound_dashboard/core/notifications/deduplication/service.py +400 -0
  43. truthound_dashboard/core/notifications/deduplication/stores.py +2365 -0
  44. truthound_dashboard/core/notifications/deduplication/strategies.py +422 -0
  45. truthound_dashboard/core/notifications/dispatcher.py +43 -0
  46. truthound_dashboard/core/notifications/escalation/__init__.py +149 -0
  47. truthound_dashboard/core/notifications/escalation/backends.py +1384 -0
  48. truthound_dashboard/core/notifications/escalation/engine.py +429 -0
  49. truthound_dashboard/core/notifications/escalation/models.py +336 -0
  50. truthound_dashboard/core/notifications/escalation/scheduler.py +1187 -0
  51. truthound_dashboard/core/notifications/escalation/state_machine.py +330 -0
  52. truthound_dashboard/core/notifications/escalation/stores.py +2896 -0
  53. truthound_dashboard/core/notifications/events.py +49 -0
  54. truthound_dashboard/core/notifications/metrics/__init__.py +115 -0
  55. truthound_dashboard/core/notifications/metrics/base.py +528 -0
  56. truthound_dashboard/core/notifications/metrics/collectors.py +583 -0
  57. truthound_dashboard/core/notifications/routing/__init__.py +169 -0
  58. truthound_dashboard/core/notifications/routing/combinators.py +184 -0
  59. truthound_dashboard/core/notifications/routing/config.py +375 -0
  60. truthound_dashboard/core/notifications/routing/config_parser.py +867 -0
  61. truthound_dashboard/core/notifications/routing/engine.py +382 -0
  62. truthound_dashboard/core/notifications/routing/expression_engine.py +1269 -0
  63. truthound_dashboard/core/notifications/routing/jinja2_engine.py +774 -0
  64. truthound_dashboard/core/notifications/routing/rules.py +625 -0
  65. truthound_dashboard/core/notifications/routing/validator.py +678 -0
  66. truthound_dashboard/core/notifications/service.py +2 -0
  67. truthound_dashboard/core/notifications/stats_aggregator.py +850 -0
  68. truthound_dashboard/core/notifications/throttling/__init__.py +83 -0
  69. truthound_dashboard/core/notifications/throttling/builder.py +311 -0
  70. truthound_dashboard/core/notifications/throttling/stores.py +1859 -0
  71. truthound_dashboard/core/notifications/throttling/throttlers.py +633 -0
  72. truthound_dashboard/core/openlineage.py +1028 -0
  73. truthound_dashboard/core/plugins/__init__.py +39 -0
  74. truthound_dashboard/core/plugins/docs/__init__.py +39 -0
  75. truthound_dashboard/core/plugins/docs/extractor.py +703 -0
  76. truthound_dashboard/core/plugins/docs/renderers.py +804 -0
  77. truthound_dashboard/core/plugins/hooks/__init__.py +63 -0
  78. truthound_dashboard/core/plugins/hooks/decorators.py +367 -0
  79. truthound_dashboard/core/plugins/hooks/manager.py +403 -0
  80. truthound_dashboard/core/plugins/hooks/protocols.py +265 -0
  81. truthound_dashboard/core/plugins/lifecycle/__init__.py +41 -0
  82. truthound_dashboard/core/plugins/lifecycle/hot_reload.py +584 -0
  83. truthound_dashboard/core/plugins/lifecycle/machine.py +419 -0
  84. truthound_dashboard/core/plugins/lifecycle/states.py +266 -0
  85. truthound_dashboard/core/plugins/loader.py +504 -0
  86. truthound_dashboard/core/plugins/registry.py +810 -0
  87. truthound_dashboard/core/plugins/reporter_executor.py +588 -0
  88. truthound_dashboard/core/plugins/sandbox/__init__.py +59 -0
  89. truthound_dashboard/core/plugins/sandbox/code_validator.py +243 -0
  90. truthound_dashboard/core/plugins/sandbox/engines.py +770 -0
  91. truthound_dashboard/core/plugins/sandbox/protocols.py +194 -0
  92. truthound_dashboard/core/plugins/sandbox.py +617 -0
  93. truthound_dashboard/core/plugins/security/__init__.py +68 -0
  94. truthound_dashboard/core/plugins/security/analyzer.py +535 -0
  95. truthound_dashboard/core/plugins/security/policies.py +311 -0
  96. truthound_dashboard/core/plugins/security/protocols.py +296 -0
  97. truthound_dashboard/core/plugins/security/signing.py +842 -0
  98. truthound_dashboard/core/plugins/security.py +446 -0
  99. truthound_dashboard/core/plugins/validator_executor.py +401 -0
  100. truthound_dashboard/core/plugins/versioning/__init__.py +51 -0
  101. truthound_dashboard/core/plugins/versioning/constraints.py +377 -0
  102. truthound_dashboard/core/plugins/versioning/dependencies.py +541 -0
  103. truthound_dashboard/core/plugins/versioning/semver.py +266 -0
  104. truthound_dashboard/core/profile_comparison.py +601 -0
  105. truthound_dashboard/core/report_history.py +570 -0
  106. truthound_dashboard/core/reporters/__init__.py +57 -0
  107. truthound_dashboard/core/reporters/base.py +296 -0
  108. truthound_dashboard/core/reporters/csv_reporter.py +155 -0
  109. truthound_dashboard/core/reporters/html_reporter.py +598 -0
  110. truthound_dashboard/core/reporters/i18n/__init__.py +65 -0
  111. truthound_dashboard/core/reporters/i18n/base.py +494 -0
  112. truthound_dashboard/core/reporters/i18n/catalogs.py +930 -0
  113. truthound_dashboard/core/reporters/json_reporter.py +160 -0
  114. truthound_dashboard/core/reporters/junit_reporter.py +233 -0
  115. truthound_dashboard/core/reporters/markdown_reporter.py +207 -0
  116. truthound_dashboard/core/reporters/pdf_reporter.py +209 -0
  117. truthound_dashboard/core/reporters/registry.py +272 -0
  118. truthound_dashboard/core/rule_generator.py +2088 -0
  119. truthound_dashboard/core/scheduler.py +822 -12
  120. truthound_dashboard/core/schema_evolution.py +858 -0
  121. truthound_dashboard/core/services.py +152 -9
  122. truthound_dashboard/core/statistics.py +718 -0
  123. truthound_dashboard/core/streaming_anomaly.py +883 -0
  124. truthound_dashboard/core/triggers/__init__.py +45 -0
  125. truthound_dashboard/core/triggers/base.py +226 -0
  126. truthound_dashboard/core/triggers/evaluators.py +609 -0
  127. truthound_dashboard/core/triggers/factory.py +363 -0
  128. truthound_dashboard/core/unified_alerts.py +870 -0
  129. truthound_dashboard/core/validation_limits.py +509 -0
  130. truthound_dashboard/core/versioning.py +709 -0
  131. truthound_dashboard/core/websocket/__init__.py +59 -0
  132. truthound_dashboard/core/websocket/manager.py +512 -0
  133. truthound_dashboard/core/websocket/messages.py +130 -0
  134. truthound_dashboard/db/__init__.py +30 -0
  135. truthound_dashboard/db/models.py +3375 -3
  136. truthound_dashboard/main.py +22 -0
  137. truthound_dashboard/schemas/__init__.py +396 -1
  138. truthound_dashboard/schemas/anomaly.py +1258 -0
  139. truthound_dashboard/schemas/base.py +4 -0
  140. truthound_dashboard/schemas/cross_alerts.py +334 -0
  141. truthound_dashboard/schemas/drift_monitor.py +890 -0
  142. truthound_dashboard/schemas/lineage.py +428 -0
  143. truthound_dashboard/schemas/maintenance.py +154 -0
  144. truthound_dashboard/schemas/model_monitoring.py +374 -0
  145. truthound_dashboard/schemas/notifications_advanced.py +1363 -0
  146. truthound_dashboard/schemas/openlineage.py +704 -0
  147. truthound_dashboard/schemas/plugins.py +1293 -0
  148. truthound_dashboard/schemas/profile.py +420 -34
  149. truthound_dashboard/schemas/profile_comparison.py +242 -0
  150. truthound_dashboard/schemas/reports.py +285 -0
  151. truthound_dashboard/schemas/rule_suggestion.py +434 -0
  152. truthound_dashboard/schemas/schema_evolution.py +164 -0
  153. truthound_dashboard/schemas/source.py +117 -2
  154. truthound_dashboard/schemas/triggers.py +511 -0
  155. truthound_dashboard/schemas/unified_alerts.py +223 -0
  156. truthound_dashboard/schemas/validation.py +25 -1
  157. truthound_dashboard/schemas/validators/__init__.py +11 -0
  158. truthound_dashboard/schemas/validators/base.py +151 -0
  159. truthound_dashboard/schemas/versioning.py +152 -0
  160. truthound_dashboard/static/index.html +2 -2
  161. {truthound_dashboard-1.3.0.dist-info → truthound_dashboard-1.4.0.dist-info}/METADATA +142 -18
  162. truthound_dashboard-1.4.0.dist-info/RECORD +239 -0
  163. truthound_dashboard/static/assets/index-BCA8H1hO.js +0 -574
  164. truthound_dashboard/static/assets/index-BNsSQ2fN.css +0 -1
  165. truthound_dashboard/static/assets/unmerged_dictionaries-CsJWCRx9.js +0 -1
  166. truthound_dashboard-1.3.0.dist-info/RECORD +0 -110
  167. {truthound_dashboard-1.3.0.dist-info → truthound_dashboard-1.4.0.dist-info}/WHEEL +0 -0
  168. {truthound_dashboard-1.3.0.dist-info → truthound_dashboard-1.4.0.dist-info}/entry_points.txt +0 -0
  169. {truthound_dashboard-1.3.0.dist-info → truthound_dashboard-1.4.0.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
+ }