violet-poolController-api 0.0.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.
- violet_poolcontroller_api/__init__.py +0 -0
- violet_poolcontroller_api/api.py +957 -0
- violet_poolcontroller_api/circuit_breaker.py +172 -0
- violet_poolcontroller_api/const_api.py +136 -0
- violet_poolcontroller_api/const_devices.py +307 -0
- violet_poolcontroller_api/utils_rate_limiter.py +235 -0
- violet_poolcontroller_api/utils_sanitizer.py +488 -0
- violet_poolcontroller_api-0.0.1.dist-info/METADATA +122 -0
- violet_poolcontroller_api-0.0.1.dist-info/RECORD +12 -0
- violet_poolcontroller_api-0.0.1.dist-info/WHEEL +5 -0
- violet_poolcontroller_api-0.0.1.dist-info/licenses/LICENSE +21 -0
- violet_poolcontroller_api-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Rate Limiter für API-Requests - Token Bucket Algorithm."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from collections import deque
|
|
9
|
+
|
|
10
|
+
_LOGGER = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RateLimiter:
|
|
14
|
+
"""
|
|
15
|
+
Rate Limiter mit Token Bucket Algorithm.
|
|
16
|
+
|
|
17
|
+
Verhindert API-Overload durch:
|
|
18
|
+
- Maximale Requests pro Zeitfenster
|
|
19
|
+
- Burst-Support für kurzzeitige Spitzen
|
|
20
|
+
- Priority Queue für kritische Requests
|
|
21
|
+
- Graceful Degradation bei Limit-Überschreitung
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
max_requests: int = 10,
|
|
27
|
+
time_window: float = 1.0,
|
|
28
|
+
burst_size: int = 3,
|
|
29
|
+
retry_after: float = 0.1,
|
|
30
|
+
) -> None:
|
|
31
|
+
"""
|
|
32
|
+
Initialisiere den Rate Limiter.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
max_requests: Maximale Anzahl Requests pro Zeitfenster
|
|
36
|
+
time_window: Zeitfenster in Sekunden
|
|
37
|
+
burst_size: Erlaubte Burst-Größe (zusätzliche Requests)
|
|
38
|
+
retry_after: Wartezeit in Sekunden bei Limit-Überschreitung
|
|
39
|
+
"""
|
|
40
|
+
self.max_requests = max_requests
|
|
41
|
+
self.time_window = time_window
|
|
42
|
+
self.burst_size = burst_size
|
|
43
|
+
self.retry_after = retry_after
|
|
44
|
+
|
|
45
|
+
# Token Bucket
|
|
46
|
+
self.tokens = float(max_requests + burst_size)
|
|
47
|
+
self.max_tokens = max_requests + burst_size
|
|
48
|
+
self.last_refill = time.monotonic()
|
|
49
|
+
|
|
50
|
+
# Optimized request history with size and time limits
|
|
51
|
+
self.request_history: deque = deque(maxlen=500) # Reduced from 1000
|
|
52
|
+
self.blocked_requests = 0
|
|
53
|
+
self.total_requests = 0
|
|
54
|
+
self.history_cleanup_interval = 300 # 5 minutes
|
|
55
|
+
self.last_cleanup_time = time.monotonic()
|
|
56
|
+
|
|
57
|
+
# Memory-efficient statistics
|
|
58
|
+
self._recent_stats = {
|
|
59
|
+
"requests_last_minute": 0,
|
|
60
|
+
"blocked_last_minute": 0,
|
|
61
|
+
"last_minute_reset": time.monotonic(),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# Lock für Thread-Safety
|
|
65
|
+
self._lock = asyncio.Lock()
|
|
66
|
+
|
|
67
|
+
_LOGGER.debug(
|
|
68
|
+
"Rate Limiter initialisiert: %d req/%ss (burst: %d)",
|
|
69
|
+
max_requests,
|
|
70
|
+
time_window,
|
|
71
|
+
burst_size,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
async def acquire(self, priority: int = 3) -> bool:
|
|
75
|
+
"""
|
|
76
|
+
Acquire a token from the rate limiter.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
priority: Priority level (0=highest, 3=lowest)
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
True if token acquired, False otherwise
|
|
83
|
+
"""
|
|
84
|
+
async with self._lock:
|
|
85
|
+
current_time = time.monotonic()
|
|
86
|
+
self.total_requests += 1
|
|
87
|
+
|
|
88
|
+
# Periodic cleanup to prevent memory growth
|
|
89
|
+
if current_time - self.last_cleanup_time > self.history_cleanup_interval:
|
|
90
|
+
await self._cleanup_history(current_time)
|
|
91
|
+
self.last_cleanup_time = current_time
|
|
92
|
+
|
|
93
|
+
# Update recent statistics
|
|
94
|
+
self._update_recent_stats(current_time)
|
|
95
|
+
|
|
96
|
+
# Refill tokens
|
|
97
|
+
await self._refill_tokens(current_time)
|
|
98
|
+
|
|
99
|
+
if self.tokens >= 1:
|
|
100
|
+
self.tokens -= 1
|
|
101
|
+
|
|
102
|
+
# Store minimal data for efficiency
|
|
103
|
+
self.request_history.append(
|
|
104
|
+
{"time": current_time, "priority": priority, "blocked": False}
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
return True
|
|
108
|
+
|
|
109
|
+
# Track failures efficiently
|
|
110
|
+
self.blocked_requests += 1
|
|
111
|
+
self._recent_stats["blocked_last_minute"] += 1
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
async def _cleanup_history(self, current_time: float) -> None:
|
|
115
|
+
"""Clean up old history entries to prevent memory leaks."""
|
|
116
|
+
# Remove entries older than 1 hour
|
|
117
|
+
cutoff_time = current_time - 3600
|
|
118
|
+
|
|
119
|
+
# Filter while maintaining order
|
|
120
|
+
filtered_history = deque(
|
|
121
|
+
(entry for entry in self.request_history if entry["time"] > cutoff_time),
|
|
122
|
+
maxlen=500,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
self.request_history = filtered_history
|
|
126
|
+
_LOGGER.debug("Rate limiter history cleanup completed")
|
|
127
|
+
|
|
128
|
+
def _update_recent_stats(self, current_time: float) -> None:
|
|
129
|
+
"""Update memory-efficient recent statistics."""
|
|
130
|
+
# Reset minute stats every 60 seconds
|
|
131
|
+
if current_time - self._recent_stats["last_minute_reset"] > 60:
|
|
132
|
+
self._recent_stats["requests_last_minute"] = 0
|
|
133
|
+
self._recent_stats["blocked_last_minute"] = 0
|
|
134
|
+
self._recent_stats["last_minute_reset"] = current_time
|
|
135
|
+
|
|
136
|
+
self._recent_stats["requests_last_minute"] += 1
|
|
137
|
+
|
|
138
|
+
async def wait_if_needed(self, priority: int = 3, timeout: float = 10.0) -> None:
|
|
139
|
+
"""
|
|
140
|
+
Warte bis ein Token verfügbar ist.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
priority: Request-Priorität
|
|
144
|
+
timeout: Maximale Wartezeit in Sekunden
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
asyncio.TimeoutError: Wenn Timeout erreicht
|
|
148
|
+
"""
|
|
149
|
+
start_time = time.monotonic()
|
|
150
|
+
|
|
151
|
+
while True:
|
|
152
|
+
if await self.acquire(priority):
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
# Timeout-Prüfung
|
|
156
|
+
elapsed = time.monotonic() - start_time
|
|
157
|
+
if elapsed >= timeout:
|
|
158
|
+
raise asyncio.TimeoutError(f"Rate Limiter timeout nach {elapsed:.1f}s")
|
|
159
|
+
|
|
160
|
+
# Warte auf Token-Refill
|
|
161
|
+
await asyncio.sleep(self.retry_after)
|
|
162
|
+
|
|
163
|
+
async def _refill_tokens(self, current_time: float) -> None:
|
|
164
|
+
"""Fülle Token-Bucket basierend auf verstrichener Zeit."""
|
|
165
|
+
time_passed = current_time - self.last_refill
|
|
166
|
+
|
|
167
|
+
# Refill proportional to time passed, not just when full window elapsed
|
|
168
|
+
if time_passed > 0:
|
|
169
|
+
# Berechne neue Tokens basierend auf verstrichener Zeit
|
|
170
|
+
refill_rate = self.max_requests / self.time_window
|
|
171
|
+
new_tokens = time_passed * refill_rate
|
|
172
|
+
|
|
173
|
+
self.tokens = min(self.max_tokens, self.tokens + new_tokens)
|
|
174
|
+
self.last_refill = current_time
|
|
175
|
+
|
|
176
|
+
def get_stats(self) -> dict:
|
|
177
|
+
"""Hole Rate-Limiter-Statistiken."""
|
|
178
|
+
current_time = time.monotonic()
|
|
179
|
+
|
|
180
|
+
# Berechne Requests in letzter Minute
|
|
181
|
+
recent_requests = [
|
|
182
|
+
r for r in self.request_history if current_time - r["time"] <= 60
|
|
183
|
+
]
|
|
184
|
+
recent_blocked = sum(1 for r in recent_requests if r["blocked"])
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
"total_requests": self.total_requests,
|
|
188
|
+
"blocked_requests": self.blocked_requests,
|
|
189
|
+
"recent_requests_1min": len(recent_requests),
|
|
190
|
+
"recent_blocked_1min": recent_blocked,
|
|
191
|
+
"current_tokens": self.tokens,
|
|
192
|
+
"max_tokens": self.max_tokens,
|
|
193
|
+
"block_rate": (
|
|
194
|
+
self.blocked_requests / self.total_requests * 100
|
|
195
|
+
if self.total_requests > 0
|
|
196
|
+
else 0
|
|
197
|
+
),
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
def reset(self) -> None:
|
|
201
|
+
"""Setze Rate Limiter zurück."""
|
|
202
|
+
self.tokens = float(self.max_tokens)
|
|
203
|
+
self.last_refill = time.monotonic()
|
|
204
|
+
self.blocked_requests = 0
|
|
205
|
+
self.total_requests = 0
|
|
206
|
+
self.request_history.clear()
|
|
207
|
+
_LOGGER.debug("Rate Limiter zurückgesetzt")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# Global Rate Limiter-Instanz (kann pro API-Instanz auch separat erstellt werden)
|
|
211
|
+
_global_rate_limiter: RateLimiter | None = None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def get_global_rate_limiter() -> RateLimiter:
|
|
215
|
+
"""Hole oder erstelle globalen Rate Limiter."""
|
|
216
|
+
global _global_rate_limiter
|
|
217
|
+
if _global_rate_limiter is None:
|
|
218
|
+
# Default-Werte aus const_api
|
|
219
|
+
from .const_api import (
|
|
220
|
+
API_RATE_LIMIT_BURST,
|
|
221
|
+
API_RATE_LIMIT_REQUESTS,
|
|
222
|
+
API_RATE_LIMIT_RETRY_AFTER,
|
|
223
|
+
API_RATE_LIMIT_WINDOW,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
_global_rate_limiter = RateLimiter(
|
|
227
|
+
max_requests=API_RATE_LIMIT_REQUESTS,
|
|
228
|
+
time_window=API_RATE_LIMIT_WINDOW,
|
|
229
|
+
burst_size=API_RATE_LIMIT_BURST,
|
|
230
|
+
retry_after=API_RATE_LIMIT_RETRY_AFTER,
|
|
231
|
+
)
|
|
232
|
+
return _global_rate_limiter
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
__all__ = ["RateLimiter", "get_global_rate_limiter"]
|