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
|
@@ -38,10 +38,68 @@ from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoin
|
|
|
38
38
|
from starlette.responses import JSONResponse
|
|
39
39
|
|
|
40
40
|
from truthound_dashboard.core.exceptions import ErrorCode
|
|
41
|
+
from truthound_dashboard.core.i18n import SupportedLocale, detect_locale
|
|
41
42
|
|
|
42
43
|
logger = logging.getLogger(__name__)
|
|
43
44
|
|
|
44
45
|
|
|
46
|
+
# =============================================================================
|
|
47
|
+
# Locale Detection
|
|
48
|
+
# =============================================================================
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class LocaleDetectionMiddleware(BaseHTTPMiddleware):
|
|
52
|
+
"""Middleware to detect and set user locale from request.
|
|
53
|
+
|
|
54
|
+
Detection priority:
|
|
55
|
+
1. Query parameter: ?lang=ko
|
|
56
|
+
2. Accept-Language header
|
|
57
|
+
3. Default locale (English)
|
|
58
|
+
|
|
59
|
+
The detected locale is stored in request.state.locale for use by
|
|
60
|
+
API endpoints that need to return localized error messages.
|
|
61
|
+
|
|
62
|
+
Example:
|
|
63
|
+
# In endpoint
|
|
64
|
+
locale = getattr(request.state, "locale", SupportedLocale.ENGLISH)
|
|
65
|
+
message = get_message("source_not_found", locale)
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
app: Any,
|
|
71
|
+
default_locale: SupportedLocale = SupportedLocale.ENGLISH,
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Initialize locale detection middleware.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
app: ASGI application.
|
|
77
|
+
default_locale: Default locale when detection fails.
|
|
78
|
+
"""
|
|
79
|
+
super().__init__(app)
|
|
80
|
+
self._default_locale = default_locale
|
|
81
|
+
|
|
82
|
+
async def dispatch(
|
|
83
|
+
self,
|
|
84
|
+
request: Request,
|
|
85
|
+
call_next: RequestResponseEndpoint,
|
|
86
|
+
) -> Response:
|
|
87
|
+
"""Detect locale and store in request state."""
|
|
88
|
+
# Detect locale from request
|
|
89
|
+
locale = detect_locale(request, default=self._default_locale)
|
|
90
|
+
|
|
91
|
+
# Store in request state
|
|
92
|
+
request.state.locale = locale
|
|
93
|
+
|
|
94
|
+
# Process request
|
|
95
|
+
response = await call_next(request)
|
|
96
|
+
|
|
97
|
+
# Add Content-Language header
|
|
98
|
+
response.headers["Content-Language"] = locale.value
|
|
99
|
+
|
|
100
|
+
return response
|
|
101
|
+
|
|
102
|
+
|
|
45
103
|
# =============================================================================
|
|
46
104
|
# Rate Limiting
|
|
47
105
|
# =============================================================================
|
|
@@ -586,6 +644,296 @@ class BasicAuthMiddleware(BaseHTTPMiddleware):
|
|
|
586
644
|
)
|
|
587
645
|
|
|
588
646
|
|
|
647
|
+
# =============================================================================
|
|
648
|
+
# Notification Throttling Middleware
|
|
649
|
+
# =============================================================================
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
@dataclass
|
|
653
|
+
class NotificationThrottleConfig:
|
|
654
|
+
"""Configuration for notification-specific throttling.
|
|
655
|
+
|
|
656
|
+
This middleware integrates with the notification throttling system
|
|
657
|
+
to provide endpoint-specific rate limiting for notification operations.
|
|
658
|
+
|
|
659
|
+
Attributes:
|
|
660
|
+
enabled: Whether throttling is enabled.
|
|
661
|
+
per_minute: Default requests per minute for notification endpoints.
|
|
662
|
+
per_hour: Default requests per hour for notification endpoints.
|
|
663
|
+
per_day: Default requests per day for notification endpoints.
|
|
664
|
+
burst_allowance: Burst allowance factor (e.g., 1.5 = 50% extra burst).
|
|
665
|
+
endpoint_overrides: Per-endpoint limit overrides.
|
|
666
|
+
"""
|
|
667
|
+
|
|
668
|
+
enabled: bool = True
|
|
669
|
+
per_minute: int = 60
|
|
670
|
+
per_hour: int = 500
|
|
671
|
+
per_day: int = 5000
|
|
672
|
+
burst_allowance: float = 1.5
|
|
673
|
+
endpoint_overrides: dict[str, dict[str, int]] = field(default_factory=dict)
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
class NotificationThrottleMiddleware(BaseHTTPMiddleware):
|
|
677
|
+
"""Middleware for notification-specific throttling.
|
|
678
|
+
|
|
679
|
+
Provides fine-grained rate limiting for notification operations with:
|
|
680
|
+
- Per-minute, per-hour, and per-day limits
|
|
681
|
+
- Endpoint-specific overrides
|
|
682
|
+
- Burst allowance
|
|
683
|
+
- Integration with notification throttling metrics
|
|
684
|
+
|
|
685
|
+
Response Headers:
|
|
686
|
+
X-RateLimit-Limit: Maximum requests in current window
|
|
687
|
+
X-RateLimit-Remaining: Remaining requests in current window
|
|
688
|
+
X-RateLimit-Reset: Unix timestamp when the limit resets
|
|
689
|
+
X-RateLimit-Window: Current limiting window (minute/hour/day)
|
|
690
|
+
Retry-After: Seconds until the limit resets (on 429 only)
|
|
691
|
+
"""
|
|
692
|
+
|
|
693
|
+
NOTIFICATION_PATHS = [
|
|
694
|
+
"/api/v1/notifications",
|
|
695
|
+
]
|
|
696
|
+
|
|
697
|
+
def __init__(
|
|
698
|
+
self,
|
|
699
|
+
app: Any,
|
|
700
|
+
config: NotificationThrottleConfig | None = None,
|
|
701
|
+
) -> None:
|
|
702
|
+
"""Initialize notification throttle middleware.
|
|
703
|
+
|
|
704
|
+
Args:
|
|
705
|
+
app: ASGI application.
|
|
706
|
+
config: Throttling configuration.
|
|
707
|
+
"""
|
|
708
|
+
super().__init__(app)
|
|
709
|
+
self._config = config or NotificationThrottleConfig()
|
|
710
|
+
|
|
711
|
+
# Separate counters for minute/hour/day windows
|
|
712
|
+
self._minute_counts: dict[str, list[float]] = defaultdict(list)
|
|
713
|
+
self._hour_counts: dict[str, list[float]] = defaultdict(list)
|
|
714
|
+
self._day_counts: dict[str, list[float]] = defaultdict(list)
|
|
715
|
+
|
|
716
|
+
# Metrics
|
|
717
|
+
self._total_requests = 0
|
|
718
|
+
self._throttled_requests = 0
|
|
719
|
+
|
|
720
|
+
async def dispatch(
|
|
721
|
+
self,
|
|
722
|
+
request: Request,
|
|
723
|
+
call_next: RequestResponseEndpoint,
|
|
724
|
+
) -> Response:
|
|
725
|
+
"""Process request with notification-specific throttling."""
|
|
726
|
+
# Only apply to notification endpoints
|
|
727
|
+
if not self._config.enabled or not self._is_notification_endpoint(request.url.path):
|
|
728
|
+
return await call_next(request)
|
|
729
|
+
|
|
730
|
+
# Get client key
|
|
731
|
+
client_ip = request.client.host if request.client else "unknown"
|
|
732
|
+
key = f"notif:{client_ip}"
|
|
733
|
+
|
|
734
|
+
self._total_requests += 1
|
|
735
|
+
|
|
736
|
+
# Get limits for this endpoint
|
|
737
|
+
limits = self._get_limits(request.url.path)
|
|
738
|
+
|
|
739
|
+
# Check all windows
|
|
740
|
+
now = time.time()
|
|
741
|
+
allowed, info = self._check_limits(key, limits, now)
|
|
742
|
+
|
|
743
|
+
if not allowed:
|
|
744
|
+
self._throttled_requests += 1
|
|
745
|
+
logger.warning(
|
|
746
|
+
f"Notification throttle exceeded for {key}",
|
|
747
|
+
extra={
|
|
748
|
+
"path": request.url.path,
|
|
749
|
+
"window": info["window"],
|
|
750
|
+
"limit": info["limit"],
|
|
751
|
+
},
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
retry_after = info["reset_at"] - int(now)
|
|
755
|
+
return JSONResponse(
|
|
756
|
+
status_code=429,
|
|
757
|
+
content={
|
|
758
|
+
"success": False,
|
|
759
|
+
"error": {
|
|
760
|
+
"code": ErrorCode.RATE_LIMIT_EXCEEDED.value,
|
|
761
|
+
"message": f"Rate limit exceeded for {info['window']} window. "
|
|
762
|
+
f"Try again in {retry_after} seconds.",
|
|
763
|
+
"details": {
|
|
764
|
+
"window": info["window"],
|
|
765
|
+
"limit": info["limit"],
|
|
766
|
+
"remaining": 0,
|
|
767
|
+
"reset_at": info["reset_at"],
|
|
768
|
+
},
|
|
769
|
+
},
|
|
770
|
+
},
|
|
771
|
+
headers=self._build_headers(info, retry_after),
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
# Record request
|
|
775
|
+
self._record_request(key, now)
|
|
776
|
+
|
|
777
|
+
# Process request
|
|
778
|
+
response = await call_next(request)
|
|
779
|
+
|
|
780
|
+
# Add rate limit headers
|
|
781
|
+
response.headers.update(self._build_headers(info))
|
|
782
|
+
|
|
783
|
+
return response
|
|
784
|
+
|
|
785
|
+
def _is_notification_endpoint(self, path: str) -> bool:
|
|
786
|
+
"""Check if path is a notification endpoint."""
|
|
787
|
+
return any(path.startswith(p) for p in self.NOTIFICATION_PATHS)
|
|
788
|
+
|
|
789
|
+
def _get_limits(self, path: str) -> dict[str, int]:
|
|
790
|
+
"""Get rate limits for a specific path."""
|
|
791
|
+
# Check for endpoint-specific overrides
|
|
792
|
+
for pattern, limits in self._config.endpoint_overrides.items():
|
|
793
|
+
if path.startswith(pattern):
|
|
794
|
+
return {
|
|
795
|
+
"minute": limits.get("per_minute", self._config.per_minute),
|
|
796
|
+
"hour": limits.get("per_hour", self._config.per_hour),
|
|
797
|
+
"day": limits.get("per_day", self._config.per_day),
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
# Return default limits with burst allowance
|
|
801
|
+
burst = self._config.burst_allowance
|
|
802
|
+
return {
|
|
803
|
+
"minute": int(self._config.per_minute * burst),
|
|
804
|
+
"hour": int(self._config.per_hour * burst),
|
|
805
|
+
"day": int(self._config.per_day * burst),
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
def _check_limits(
|
|
809
|
+
self,
|
|
810
|
+
key: str,
|
|
811
|
+
limits: dict[str, int],
|
|
812
|
+
now: float,
|
|
813
|
+
) -> tuple[bool, dict[str, Any]]:
|
|
814
|
+
"""Check all rate limit windows.
|
|
815
|
+
|
|
816
|
+
Returns:
|
|
817
|
+
Tuple of (allowed, info dict).
|
|
818
|
+
"""
|
|
819
|
+
# Clean old entries
|
|
820
|
+
self._cleanup_window(key, now)
|
|
821
|
+
|
|
822
|
+
# Check minute limit first (strictest)
|
|
823
|
+
minute_count = len(self._minute_counts[key])
|
|
824
|
+
if minute_count >= limits["minute"]:
|
|
825
|
+
return False, {
|
|
826
|
+
"window": "minute",
|
|
827
|
+
"limit": limits["minute"],
|
|
828
|
+
"remaining": 0,
|
|
829
|
+
"reset_at": int(now + 60 - (now % 60)),
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
# Check hour limit
|
|
833
|
+
hour_count = len(self._hour_counts[key])
|
|
834
|
+
if hour_count >= limits["hour"]:
|
|
835
|
+
return False, {
|
|
836
|
+
"window": "hour",
|
|
837
|
+
"limit": limits["hour"],
|
|
838
|
+
"remaining": 0,
|
|
839
|
+
"reset_at": int(now + 3600 - (now % 3600)),
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
# Check day limit
|
|
843
|
+
day_count = len(self._day_counts[key])
|
|
844
|
+
if day_count >= limits["day"]:
|
|
845
|
+
return False, {
|
|
846
|
+
"window": "day",
|
|
847
|
+
"limit": limits["day"],
|
|
848
|
+
"remaining": 0,
|
|
849
|
+
"reset_at": int(now + 86400 - (now % 86400)),
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
# All windows have capacity - return most restrictive remaining
|
|
853
|
+
minute_remaining = limits["minute"] - minute_count
|
|
854
|
+
hour_remaining = limits["hour"] - hour_count
|
|
855
|
+
day_remaining = limits["day"] - day_count
|
|
856
|
+
|
|
857
|
+
# Find the most restrictive window
|
|
858
|
+
if minute_remaining <= hour_remaining and minute_remaining <= day_remaining:
|
|
859
|
+
return True, {
|
|
860
|
+
"window": "minute",
|
|
861
|
+
"limit": limits["minute"],
|
|
862
|
+
"remaining": minute_remaining - 1,
|
|
863
|
+
"reset_at": int(now + 60 - (now % 60)),
|
|
864
|
+
}
|
|
865
|
+
elif hour_remaining <= day_remaining:
|
|
866
|
+
return True, {
|
|
867
|
+
"window": "hour",
|
|
868
|
+
"limit": limits["hour"],
|
|
869
|
+
"remaining": hour_remaining - 1,
|
|
870
|
+
"reset_at": int(now + 3600 - (now % 3600)),
|
|
871
|
+
}
|
|
872
|
+
else:
|
|
873
|
+
return True, {
|
|
874
|
+
"window": "day",
|
|
875
|
+
"limit": limits["day"],
|
|
876
|
+
"remaining": day_remaining - 1,
|
|
877
|
+
"reset_at": int(now + 86400 - (now % 86400)),
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
def _record_request(self, key: str, now: float) -> None:
|
|
881
|
+
"""Record a request in all windows."""
|
|
882
|
+
self._minute_counts[key].append(now)
|
|
883
|
+
self._hour_counts[key].append(now)
|
|
884
|
+
self._day_counts[key].append(now)
|
|
885
|
+
|
|
886
|
+
def _cleanup_window(self, key: str, now: float) -> None:
|
|
887
|
+
"""Remove expired entries from all windows."""
|
|
888
|
+
minute_start = now - 60
|
|
889
|
+
hour_start = now - 3600
|
|
890
|
+
day_start = now - 86400
|
|
891
|
+
|
|
892
|
+
self._minute_counts[key] = [
|
|
893
|
+
t for t in self._minute_counts[key] if t > minute_start
|
|
894
|
+
]
|
|
895
|
+
self._hour_counts[key] = [
|
|
896
|
+
t for t in self._hour_counts[key] if t > hour_start
|
|
897
|
+
]
|
|
898
|
+
self._day_counts[key] = [
|
|
899
|
+
t for t in self._day_counts[key] if t > day_start
|
|
900
|
+
]
|
|
901
|
+
|
|
902
|
+
def _build_headers(
|
|
903
|
+
self,
|
|
904
|
+
info: dict[str, Any],
|
|
905
|
+
retry_after: int | None = None,
|
|
906
|
+
) -> dict[str, str]:
|
|
907
|
+
"""Build rate limit response headers."""
|
|
908
|
+
headers = {
|
|
909
|
+
"X-RateLimit-Limit": str(info["limit"]),
|
|
910
|
+
"X-RateLimit-Remaining": str(max(0, info["remaining"])),
|
|
911
|
+
"X-RateLimit-Reset": str(info["reset_at"]),
|
|
912
|
+
"X-RateLimit-Window": info["window"],
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
if retry_after is not None:
|
|
916
|
+
headers["Retry-After"] = str(max(0, retry_after))
|
|
917
|
+
|
|
918
|
+
return headers
|
|
919
|
+
|
|
920
|
+
def get_metrics(self) -> dict[str, Any]:
|
|
921
|
+
"""Get throttling metrics.
|
|
922
|
+
|
|
923
|
+
Returns:
|
|
924
|
+
Dictionary with throttling statistics.
|
|
925
|
+
"""
|
|
926
|
+
return {
|
|
927
|
+
"total_requests": self._total_requests,
|
|
928
|
+
"throttled_requests": self._throttled_requests,
|
|
929
|
+
"throttle_rate": (
|
|
930
|
+
self._throttled_requests / self._total_requests * 100
|
|
931
|
+
if self._total_requests > 0 else 0
|
|
932
|
+
),
|
|
933
|
+
"active_keys": len(self._minute_counts),
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
|
|
589
937
|
# =============================================================================
|
|
590
938
|
# Middleware Setup Helper
|
|
591
939
|
# =============================================================================
|
|
@@ -606,13 +954,34 @@ def setup_middleware(app: Any) -> None:
|
|
|
606
954
|
# Request logging (always enabled)
|
|
607
955
|
app.add_middleware(RequestLoggingMiddleware)
|
|
608
956
|
|
|
609
|
-
# Rate limiting
|
|
957
|
+
# Rate limiting (general)
|
|
610
958
|
rate_limit_config = RateLimitConfig(
|
|
611
959
|
requests_per_minute=120,
|
|
612
960
|
exclude_paths=["/health", "/docs", "/redoc", "/openapi.json", "/api/openapi.json"],
|
|
613
961
|
)
|
|
614
962
|
app.add_middleware(RateLimitMiddleware, config=rate_limit_config)
|
|
615
963
|
|
|
964
|
+
# Notification-specific throttling (more granular)
|
|
965
|
+
notif_throttle_config = NotificationThrottleConfig(
|
|
966
|
+
per_minute=60,
|
|
967
|
+
per_hour=500,
|
|
968
|
+
per_day=5000,
|
|
969
|
+
burst_allowance=1.5,
|
|
970
|
+
endpoint_overrides={
|
|
971
|
+
# Higher limits for read operations
|
|
972
|
+
"/api/v1/notifications/routing/rules": {
|
|
973
|
+
"per_minute": 120,
|
|
974
|
+
"per_hour": 1000,
|
|
975
|
+
},
|
|
976
|
+
# Lower limits for write operations
|
|
977
|
+
"/api/v1/notifications/escalation/incidents": {
|
|
978
|
+
"per_minute": 30,
|
|
979
|
+
"per_hour": 200,
|
|
980
|
+
},
|
|
981
|
+
},
|
|
982
|
+
)
|
|
983
|
+
app.add_middleware(NotificationThrottleMiddleware, config=notif_throttle_config)
|
|
984
|
+
|
|
616
985
|
# Security headers
|
|
617
986
|
app.add_middleware(SecurityHeadersMiddleware)
|
|
618
987
|
|
|
@@ -623,4 +992,7 @@ def setup_middleware(app: Any) -> None:
|
|
|
623
992
|
password=settings.auth_password,
|
|
624
993
|
)
|
|
625
994
|
|
|
995
|
+
# Locale detection (runs first, so it's available to all other middleware)
|
|
996
|
+
app.add_middleware(LocaleDetectionMiddleware)
|
|
997
|
+
|
|
626
998
|
logger.info("Middleware configured successfully")
|