dexscreen 0.0.1__py3-none-any.whl → 0.0.4__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.
- dexscreen/__init__.py +87 -0
- dexscreen/api/client.py +275 -42
- dexscreen/core/exceptions.py +1067 -0
- dexscreen/core/http.py +861 -117
- dexscreen/core/validators.py +542 -0
- dexscreen/stream/polling.py +288 -78
- dexscreen/utils/__init__.py +54 -1
- dexscreen/utils/filters.py +182 -12
- dexscreen/utils/logging_config.py +421 -0
- dexscreen/utils/middleware.py +363 -0
- dexscreen/utils/ratelimit.py +212 -8
- dexscreen/utils/retry.py +357 -0
- {dexscreen-0.0.1.dist-info → dexscreen-0.0.4.dist-info}/METADATA +52 -1
- dexscreen-0.0.4.dist-info/RECORD +22 -0
- dexscreen-0.0.1.dist-info/RECORD +0 -17
- {dexscreen-0.0.1.dist-info → dexscreen-0.0.4.dist-info}/WHEEL +0 -0
- {dexscreen-0.0.1.dist-info → dexscreen-0.0.4.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,363 @@
|
|
1
|
+
"""
|
2
|
+
Middleware utilities for request tracking and correlation ID propagation
|
3
|
+
"""
|
4
|
+
|
5
|
+
import inspect
|
6
|
+
import time
|
7
|
+
from functools import wraps
|
8
|
+
from typing import Any, Callable, Optional, TypeVar
|
9
|
+
|
10
|
+
from .logging_config import (
|
11
|
+
generate_correlation_id,
|
12
|
+
get_contextual_logger,
|
13
|
+
get_correlation_id,
|
14
|
+
set_correlation_id,
|
15
|
+
with_correlation_id,
|
16
|
+
)
|
17
|
+
|
18
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
19
|
+
|
20
|
+
|
21
|
+
class RequestTracker:
|
22
|
+
"""Tracks request lifecycle and propagates correlation IDs"""
|
23
|
+
|
24
|
+
def __init__(self):
|
25
|
+
self.contextual_logger = get_contextual_logger(__name__)
|
26
|
+
self.active_requests: dict[str, dict[str, Any]] = {}
|
27
|
+
|
28
|
+
def start_request(self, operation: str, context: Optional[dict[str, Any]] = None) -> str:
|
29
|
+
"""Start tracking a new request and return correlation ID"""
|
30
|
+
correlation_id = generate_correlation_id()
|
31
|
+
set_correlation_id(correlation_id)
|
32
|
+
|
33
|
+
request_info = {
|
34
|
+
"operation": operation,
|
35
|
+
"correlation_id": correlation_id,
|
36
|
+
"start_time": time.time(),
|
37
|
+
"context": context or {},
|
38
|
+
"status": "active",
|
39
|
+
}
|
40
|
+
|
41
|
+
self.active_requests[correlation_id] = request_info
|
42
|
+
|
43
|
+
track_context = {
|
44
|
+
"operation": "request_start",
|
45
|
+
"correlation_id": correlation_id,
|
46
|
+
"tracked_operation": operation,
|
47
|
+
"active_requests_count": len(self.active_requests),
|
48
|
+
**request_info["context"],
|
49
|
+
}
|
50
|
+
|
51
|
+
self.contextual_logger.info("Starting request tracking for %s", operation, context=track_context)
|
52
|
+
|
53
|
+
return correlation_id
|
54
|
+
|
55
|
+
def end_request(
|
56
|
+
self,
|
57
|
+
correlation_id: Optional[str] = None,
|
58
|
+
status: str = "completed",
|
59
|
+
result_context: Optional[dict[str, Any]] = None,
|
60
|
+
) -> Optional[dict[str, Any]]:
|
61
|
+
"""End request tracking and return request info"""
|
62
|
+
if correlation_id is None:
|
63
|
+
correlation_id = get_correlation_id()
|
64
|
+
|
65
|
+
if not correlation_id or correlation_id not in self.active_requests:
|
66
|
+
self.contextual_logger.warning(
|
67
|
+
"Attempted to end request tracking with unknown correlation ID: %s",
|
68
|
+
correlation_id,
|
69
|
+
context={"operation": "request_end_unknown", "correlation_id": correlation_id},
|
70
|
+
)
|
71
|
+
return None
|
72
|
+
|
73
|
+
request_info = self.active_requests.pop(correlation_id)
|
74
|
+
request_info["end_time"] = time.time()
|
75
|
+
request_info["duration"] = request_info["end_time"] - request_info["start_time"]
|
76
|
+
request_info["status"] = status
|
77
|
+
request_info["result_context"] = result_context or {}
|
78
|
+
|
79
|
+
track_context = {
|
80
|
+
"operation": "request_end",
|
81
|
+
"correlation_id": correlation_id,
|
82
|
+
"tracked_operation": request_info["operation"],
|
83
|
+
"duration": request_info["duration"],
|
84
|
+
"status": status,
|
85
|
+
"active_requests_count": len(self.active_requests),
|
86
|
+
**request_info["context"],
|
87
|
+
**request_info["result_context"],
|
88
|
+
}
|
89
|
+
|
90
|
+
log_level = "info" if status == "completed" else "warning" if status == "failed" else "error"
|
91
|
+
log_method = getattr(self.contextual_logger, log_level)
|
92
|
+
|
93
|
+
log_method(
|
94
|
+
"Completed request tracking for %s (%.3fs, %s)",
|
95
|
+
request_info["operation"],
|
96
|
+
request_info["duration"],
|
97
|
+
status,
|
98
|
+
context=track_context,
|
99
|
+
)
|
100
|
+
|
101
|
+
return request_info
|
102
|
+
|
103
|
+
def get_active_requests(self) -> dict[str, dict[str, Any]]:
|
104
|
+
"""Get all currently active requests"""
|
105
|
+
return self.active_requests.copy()
|
106
|
+
|
107
|
+
def log_active_requests(self):
|
108
|
+
"""Log information about currently active requests"""
|
109
|
+
if not self.active_requests:
|
110
|
+
self.contextual_logger.debug("No active requests", context={"operation": "active_requests_check"})
|
111
|
+
return
|
112
|
+
|
113
|
+
current_time = time.time()
|
114
|
+
requests_info = []
|
115
|
+
|
116
|
+
for correlation_id, info in self.active_requests.items():
|
117
|
+
duration = current_time - info["start_time"]
|
118
|
+
requests_info.append(
|
119
|
+
{
|
120
|
+
"correlation_id": correlation_id,
|
121
|
+
"operation": info["operation"],
|
122
|
+
"duration": duration,
|
123
|
+
}
|
124
|
+
)
|
125
|
+
|
126
|
+
active_context = {
|
127
|
+
"operation": "active_requests_summary",
|
128
|
+
"active_count": len(self.active_requests),
|
129
|
+
"requests": requests_info,
|
130
|
+
}
|
131
|
+
|
132
|
+
self.contextual_logger.info(
|
133
|
+
"%d active requests currently being tracked", len(self.active_requests), context=active_context
|
134
|
+
)
|
135
|
+
|
136
|
+
|
137
|
+
# Global request tracker instance
|
138
|
+
_request_tracker = RequestTracker()
|
139
|
+
|
140
|
+
|
141
|
+
def track_request(operation: str, include_args: bool = False, include_result: bool = False):
|
142
|
+
"""
|
143
|
+
Decorator to automatically track request lifecycle with correlation IDs.
|
144
|
+
|
145
|
+
Args:
|
146
|
+
operation: Name of the operation being tracked
|
147
|
+
include_args: Whether to include function arguments in context
|
148
|
+
include_result: Whether to include function result in context
|
149
|
+
"""
|
150
|
+
|
151
|
+
def decorator(func: F) -> F:
|
152
|
+
@wraps(func)
|
153
|
+
def sync_wrapper(*args, **kwargs):
|
154
|
+
# Build context
|
155
|
+
context: dict[str, Any] = {"function_name": func.__name__}
|
156
|
+
if include_args:
|
157
|
+
context["args"] = _sanitize_args(args, kwargs)
|
158
|
+
|
159
|
+
# Start tracking
|
160
|
+
correlation_id = _request_tracker.start_request(operation, context)
|
161
|
+
|
162
|
+
try:
|
163
|
+
result = func(*args, **kwargs)
|
164
|
+
|
165
|
+
# Build result context
|
166
|
+
result_context = {}
|
167
|
+
if include_result:
|
168
|
+
result_context["result"] = _sanitize_result(result)
|
169
|
+
|
170
|
+
_request_tracker.end_request(correlation_id, "completed", result_context)
|
171
|
+
return result
|
172
|
+
|
173
|
+
except Exception as e:
|
174
|
+
error_context = {
|
175
|
+
"error_type": type(e).__name__,
|
176
|
+
"error_message": str(e),
|
177
|
+
}
|
178
|
+
_request_tracker.end_request(correlation_id, "failed", error_context)
|
179
|
+
raise
|
180
|
+
|
181
|
+
@wraps(func)
|
182
|
+
async def async_wrapper(*args, **kwargs):
|
183
|
+
# Build context
|
184
|
+
context: dict[str, Any] = {"function_name": func.__name__}
|
185
|
+
if include_args:
|
186
|
+
context["args"] = _sanitize_args(args, kwargs)
|
187
|
+
|
188
|
+
# Start tracking
|
189
|
+
correlation_id = _request_tracker.start_request(operation, context)
|
190
|
+
|
191
|
+
try:
|
192
|
+
result = await func(*args, **kwargs)
|
193
|
+
|
194
|
+
# Build result context
|
195
|
+
result_context = {}
|
196
|
+
if include_result:
|
197
|
+
result_context["result"] = _sanitize_result(result)
|
198
|
+
|
199
|
+
_request_tracker.end_request(correlation_id, "completed", result_context)
|
200
|
+
return result
|
201
|
+
|
202
|
+
except Exception as e:
|
203
|
+
error_context = {
|
204
|
+
"error_type": type(e).__name__,
|
205
|
+
"error_message": str(e),
|
206
|
+
}
|
207
|
+
_request_tracker.end_request(correlation_id, "failed", error_context)
|
208
|
+
raise
|
209
|
+
|
210
|
+
if inspect.iscoroutinefunction(func):
|
211
|
+
return async_wrapper # type: ignore[return-value]
|
212
|
+
else:
|
213
|
+
return sync_wrapper # type: ignore[return-value]
|
214
|
+
|
215
|
+
return decorator
|
216
|
+
|
217
|
+
|
218
|
+
def _sanitize_args(args: tuple, kwargs: dict) -> dict[str, Any]:
|
219
|
+
"""Sanitize function arguments for logging"""
|
220
|
+
sanitized = {}
|
221
|
+
|
222
|
+
# Limit args to first 3 to avoid log bloat
|
223
|
+
if args:
|
224
|
+
sanitized["positional"] = [_sanitize_value(arg) for arg in args[:3]]
|
225
|
+
if len(args) > 3:
|
226
|
+
sanitized["positional_truncated"] = len(args)
|
227
|
+
|
228
|
+
# Sanitize kwargs
|
229
|
+
if kwargs:
|
230
|
+
sanitized["keyword"] = {k: _sanitize_value(v) for k, v in list(kwargs.items())[:5]}
|
231
|
+
if len(kwargs) > 5:
|
232
|
+
sanitized["keyword_truncated"] = len(kwargs)
|
233
|
+
|
234
|
+
return sanitized
|
235
|
+
|
236
|
+
|
237
|
+
def _sanitize_value(value: Any) -> Any:
|
238
|
+
"""Sanitize a single value for logging"""
|
239
|
+
if isinstance(value, str):
|
240
|
+
# Truncate long strings
|
241
|
+
return value[:100] + "..." if len(value) > 100 else value
|
242
|
+
elif isinstance(value, (list, tuple)):
|
243
|
+
# Show length for collections
|
244
|
+
return f"<{type(value).__name__} of {len(value)} items>"
|
245
|
+
elif isinstance(value, dict):
|
246
|
+
return f"<dict with {len(value)} keys>"
|
247
|
+
elif hasattr(value, "__dict__"):
|
248
|
+
# Object with attributes
|
249
|
+
return f"<{type(value).__name__} instance>"
|
250
|
+
else:
|
251
|
+
return value
|
252
|
+
|
253
|
+
|
254
|
+
def _sanitize_result(result: Any) -> Any:
|
255
|
+
"""Sanitize function result for logging"""
|
256
|
+
if isinstance(result, (list, tuple)):
|
257
|
+
return {"type": type(result).__name__, "length": len(result)}
|
258
|
+
elif isinstance(result, dict):
|
259
|
+
return {"type": "dict", "keys": len(result)}
|
260
|
+
elif hasattr(result, "__dict__"):
|
261
|
+
return {"type": type(result).__name__}
|
262
|
+
else:
|
263
|
+
return _sanitize_value(result)
|
264
|
+
|
265
|
+
|
266
|
+
class CorrelationMiddleware:
|
267
|
+
"""Middleware for automatic correlation ID management in HTTP requests"""
|
268
|
+
|
269
|
+
def __init__(self):
|
270
|
+
self.contextual_logger = get_contextual_logger(__name__)
|
271
|
+
|
272
|
+
def wrap_http_client(self, client_class):
|
273
|
+
"""Wrap an HTTP client class to add correlation ID headers"""
|
274
|
+
original_request = client_class.request
|
275
|
+
original_request_async = getattr(client_class, "request_async", None)
|
276
|
+
|
277
|
+
@with_correlation_id()
|
278
|
+
def wrapped_request(self, method, url, **kwargs):
|
279
|
+
correlation_id = get_correlation_id()
|
280
|
+
if correlation_id:
|
281
|
+
# Add correlation ID to headers
|
282
|
+
headers = kwargs.get("headers", {})
|
283
|
+
headers["X-Correlation-ID"] = correlation_id
|
284
|
+
kwargs["headers"] = headers
|
285
|
+
|
286
|
+
middleware_context = {
|
287
|
+
"operation": "http_request_correlation",
|
288
|
+
"method": method,
|
289
|
+
"url": url[:100] + "..." if len(url) > 100 else url,
|
290
|
+
"correlation_id": correlation_id,
|
291
|
+
"has_existing_headers": bool(kwargs.get("headers", {})),
|
292
|
+
}
|
293
|
+
|
294
|
+
self.contextual_logger.debug(
|
295
|
+
"Adding correlation ID to HTTP %s request", method, context=middleware_context
|
296
|
+
)
|
297
|
+
|
298
|
+
return original_request(self, method, url, **kwargs)
|
299
|
+
|
300
|
+
if original_request_async:
|
301
|
+
|
302
|
+
@with_correlation_id()
|
303
|
+
async def wrapped_request_async(self, method, url, **kwargs):
|
304
|
+
correlation_id = get_correlation_id()
|
305
|
+
if correlation_id:
|
306
|
+
# Add correlation ID to headers
|
307
|
+
headers = kwargs.get("headers", {})
|
308
|
+
headers["X-Correlation-ID"] = correlation_id
|
309
|
+
kwargs["headers"] = headers
|
310
|
+
|
311
|
+
middleware_context = {
|
312
|
+
"operation": "async_http_request_correlation",
|
313
|
+
"method": method,
|
314
|
+
"url": url[:100] + "..." if len(url) > 100 else url,
|
315
|
+
"correlation_id": correlation_id,
|
316
|
+
"has_existing_headers": bool(kwargs.get("headers", {})),
|
317
|
+
}
|
318
|
+
|
319
|
+
self.contextual_logger.debug(
|
320
|
+
"Adding correlation ID to async HTTP %s request", method, context=middleware_context
|
321
|
+
)
|
322
|
+
|
323
|
+
return await original_request_async(self, method, url, **kwargs)
|
324
|
+
|
325
|
+
client_class.request_async = wrapped_request_async
|
326
|
+
|
327
|
+
client_class.request = wrapped_request
|
328
|
+
return client_class
|
329
|
+
|
330
|
+
|
331
|
+
# Global middleware instance
|
332
|
+
_correlation_middleware = CorrelationMiddleware()
|
333
|
+
|
334
|
+
|
335
|
+
def get_request_tracker() -> RequestTracker:
|
336
|
+
"""Get the global request tracker instance"""
|
337
|
+
return _request_tracker
|
338
|
+
|
339
|
+
|
340
|
+
def get_correlation_middleware() -> CorrelationMiddleware:
|
341
|
+
"""Get the global correlation middleware instance"""
|
342
|
+
return _correlation_middleware
|
343
|
+
|
344
|
+
|
345
|
+
def auto_track_requests(operation_prefix: str = "api"):
|
346
|
+
"""
|
347
|
+
Class decorator to automatically add request tracking to all public methods.
|
348
|
+
|
349
|
+
Args:
|
350
|
+
operation_prefix: Prefix for operation names (method names will be appended)
|
351
|
+
"""
|
352
|
+
|
353
|
+
def decorator(cls):
|
354
|
+
for attr_name in dir(cls):
|
355
|
+
if not attr_name.startswith("_"): # Only public methods
|
356
|
+
attr = getattr(cls, attr_name)
|
357
|
+
if callable(attr):
|
358
|
+
operation_name = f"{operation_prefix}_{attr_name}"
|
359
|
+
tracked_method = track_request(operation_name)(attr)
|
360
|
+
setattr(cls, attr_name, tracked_method)
|
361
|
+
return cls
|
362
|
+
|
363
|
+
return decorator
|
dexscreen/utils/ratelimit.py
CHANGED
@@ -3,12 +3,9 @@ import collections
|
|
3
3
|
import threading
|
4
4
|
import time
|
5
5
|
from collections import deque
|
6
|
+
from typing import Any
|
6
7
|
|
7
|
-
|
8
|
-
class RateLimitError(Exception):
|
9
|
-
"""Raised when rate limit is exceeded"""
|
10
|
-
|
11
|
-
pass
|
8
|
+
from .logging_config import get_contextual_logger, with_correlation_id
|
12
9
|
|
13
10
|
|
14
11
|
class RateLimiter:
|
@@ -21,12 +18,75 @@ class RateLimiter:
|
|
21
18
|
self.sync_lock = threading.Lock()
|
22
19
|
self.async_lock = asyncio.Lock()
|
23
20
|
|
21
|
+
# Enhanced logging
|
22
|
+
self.contextual_logger = get_contextual_logger(__name__)
|
23
|
+
|
24
|
+
# Rate limiting statistics
|
25
|
+
self.stats = {
|
26
|
+
"total_requests": 0,
|
27
|
+
"blocked_requests": 0,
|
28
|
+
"total_wait_time": 0.0,
|
29
|
+
"max_wait_time": 0.0,
|
30
|
+
"average_wait_time": 0.0,
|
31
|
+
"calls_in_current_window": 0,
|
32
|
+
"window_start_time": None,
|
33
|
+
}
|
34
|
+
|
35
|
+
init_context = {
|
36
|
+
"max_calls": max_calls,
|
37
|
+
"period": period,
|
38
|
+
"rate_per_second": max_calls / period,
|
39
|
+
}
|
40
|
+
|
41
|
+
self.contextual_logger.debug("RateLimiter initialized", context=init_context)
|
42
|
+
|
43
|
+
@with_correlation_id()
|
24
44
|
def __enter__(self):
|
25
45
|
with self.sync_lock:
|
46
|
+
self.stats["total_requests"] += 1
|
26
47
|
sleep_time = self.get_sleep_time()
|
27
48
|
|
49
|
+
rate_limit_context = {
|
50
|
+
"operation": "sync_rate_limit_enter",
|
51
|
+
"sleep_time": sleep_time,
|
52
|
+
"calls_in_window": len(self.calls),
|
53
|
+
"max_calls": self.max_calls,
|
54
|
+
"period": self.period,
|
55
|
+
"will_block": sleep_time > 0,
|
56
|
+
}
|
57
|
+
|
28
58
|
if sleep_time > 0:
|
59
|
+
self.stats["blocked_requests"] += 1
|
60
|
+
self.stats["total_wait_time"] += sleep_time
|
61
|
+
self.stats["max_wait_time"] = max(self.stats["max_wait_time"], sleep_time)
|
62
|
+
|
63
|
+
# Update average wait time
|
64
|
+
if self.stats["blocked_requests"] > 0:
|
65
|
+
self.stats["average_wait_time"] = self.stats["total_wait_time"] / self.stats["blocked_requests"]
|
66
|
+
|
67
|
+
rate_limit_context["blocking_duration"] = sleep_time
|
68
|
+
|
69
|
+
self.contextual_logger.warning(
|
70
|
+
"Rate limit exceeded, sleeping for %.3fs (calls: %d/%d)",
|
71
|
+
sleep_time,
|
72
|
+
len(self.calls),
|
73
|
+
self.max_calls,
|
74
|
+
context=rate_limit_context,
|
75
|
+
)
|
76
|
+
|
77
|
+
start_time = time.time()
|
29
78
|
time.sleep(sleep_time)
|
79
|
+
actual_sleep = time.time() - start_time
|
80
|
+
|
81
|
+
if abs(actual_sleep - sleep_time) > 0.1: # More than 100ms difference
|
82
|
+
self.contextual_logger.debug(
|
83
|
+
"Sleep time deviation: expected %.3fs, actual %.3fs",
|
84
|
+
sleep_time,
|
85
|
+
actual_sleep,
|
86
|
+
context={"expected_sleep": sleep_time, "actual_sleep": actual_sleep},
|
87
|
+
)
|
88
|
+
else:
|
89
|
+
self.contextual_logger.debug("Rate limit check passed", context=rate_limit_context)
|
30
90
|
|
31
91
|
return self
|
32
92
|
|
@@ -34,12 +94,58 @@ class RateLimiter:
|
|
34
94
|
with self.sync_lock:
|
35
95
|
self._clear_calls()
|
36
96
|
|
97
|
+
# Update current window stats
|
98
|
+
self.stats["calls_in_current_window"] = len(self.calls)
|
99
|
+
if self.calls and self.stats["window_start_time"] is None:
|
100
|
+
self.stats["window_start_time"] = self.calls[0]
|
101
|
+
|
102
|
+
@with_correlation_id()
|
37
103
|
async def __aenter__(self):
|
38
104
|
async with self.async_lock:
|
105
|
+
self.stats["total_requests"] += 1
|
39
106
|
sleep_time = self.get_sleep_time()
|
40
107
|
|
108
|
+
rate_limit_context = {
|
109
|
+
"operation": "async_rate_limit_enter",
|
110
|
+
"sleep_time": sleep_time,
|
111
|
+
"calls_in_window": len(self.calls),
|
112
|
+
"max_calls": self.max_calls,
|
113
|
+
"period": self.period,
|
114
|
+
"will_block": sleep_time > 0,
|
115
|
+
}
|
116
|
+
|
41
117
|
if sleep_time > 0:
|
118
|
+
self.stats["blocked_requests"] += 1
|
119
|
+
self.stats["total_wait_time"] += sleep_time
|
120
|
+
self.stats["max_wait_time"] = max(self.stats["max_wait_time"], sleep_time)
|
121
|
+
|
122
|
+
# Update average wait time
|
123
|
+
if self.stats["blocked_requests"] > 0:
|
124
|
+
self.stats["average_wait_time"] = self.stats["total_wait_time"] / self.stats["blocked_requests"]
|
125
|
+
|
126
|
+
rate_limit_context["blocking_duration"] = sleep_time
|
127
|
+
|
128
|
+
self.contextual_logger.warning(
|
129
|
+
"Async rate limit exceeded, sleeping for %.3fs (calls: %d/%d)",
|
130
|
+
sleep_time,
|
131
|
+
len(self.calls),
|
132
|
+
self.max_calls,
|
133
|
+
context=rate_limit_context,
|
134
|
+
)
|
135
|
+
|
136
|
+
start_time = time.time()
|
42
137
|
await asyncio.sleep(sleep_time)
|
138
|
+
actual_sleep = time.time() - start_time
|
139
|
+
|
140
|
+
if abs(actual_sleep - sleep_time) > 0.1: # More than 100ms difference
|
141
|
+
self.contextual_logger.debug(
|
142
|
+
"Async sleep time deviation: expected %.3fs, actual %.3fs",
|
143
|
+
sleep_time,
|
144
|
+
actual_sleep,
|
145
|
+
context={"expected_sleep": sleep_time, "actual_sleep": actual_sleep},
|
146
|
+
)
|
147
|
+
else:
|
148
|
+
self.contextual_logger.debug("Async rate limit check passed", context=rate_limit_context)
|
43
149
|
|
44
150
|
return self
|
45
151
|
|
@@ -47,19 +153,117 @@ class RateLimiter:
|
|
47
153
|
async with self.async_lock:
|
48
154
|
self._clear_calls()
|
49
155
|
|
156
|
+
# Update current window stats
|
157
|
+
self.stats["calls_in_current_window"] = len(self.calls)
|
158
|
+
if self.calls and self.stats["window_start_time"] is None:
|
159
|
+
self.stats["window_start_time"] = self.calls[0]
|
160
|
+
|
50
161
|
def get_sleep_time(self) -> float:
|
162
|
+
"""Calculate how long to sleep before allowing the next call"""
|
51
163
|
if len(self.calls) >= self.max_calls:
|
52
164
|
until = time.time() + self.period - self._timespan
|
53
|
-
|
165
|
+
sleep_time = until - time.time()
|
166
|
+
|
167
|
+
# Log when rate limit calculations result in significant wait times
|
168
|
+
if sleep_time > 1.0: # More than 1 second
|
169
|
+
sleep_context = {
|
170
|
+
"operation": "calculate_sleep_time",
|
171
|
+
"calculated_sleep": sleep_time,
|
172
|
+
"calls_in_window": len(self.calls),
|
173
|
+
"window_timespan": self._timespan,
|
174
|
+
"max_calls": self.max_calls,
|
175
|
+
"period": self.period,
|
176
|
+
"utilization_percent": (len(self.calls) / self.max_calls) * 100,
|
177
|
+
}
|
178
|
+
|
179
|
+
self.contextual_logger.debug(
|
180
|
+
"Rate limit calculation: need to sleep %.3fs (window %.1f%% full)",
|
181
|
+
sleep_time,
|
182
|
+
sleep_context["utilization_percent"],
|
183
|
+
context=sleep_context,
|
184
|
+
)
|
185
|
+
|
186
|
+
return max(0, sleep_time) # Ensure non-negative
|
54
187
|
|
55
188
|
return 0
|
56
189
|
|
57
190
|
def _clear_calls(self):
|
58
|
-
|
191
|
+
"""Add current call and remove expired calls from the sliding window"""
|
192
|
+
current_time = time.time()
|
193
|
+
calls_before = len(self.calls)
|
194
|
+
|
195
|
+
self.calls.append(current_time)
|
59
196
|
|
60
|
-
|
197
|
+
# Remove expired calls
|
198
|
+
while len(self.calls) > 1 and self._timespan >= self.period:
|
61
199
|
self.calls.popleft()
|
62
200
|
|
201
|
+
calls_after = len(self.calls)
|
202
|
+
expired_calls = calls_before + 1 - calls_after # +1 for the new call we just added
|
203
|
+
|
204
|
+
if expired_calls > 0:
|
205
|
+
clear_context = {
|
206
|
+
"operation": "clear_expired_calls",
|
207
|
+
"expired_calls": expired_calls,
|
208
|
+
"calls_remaining": calls_after,
|
209
|
+
"window_timespan": self._timespan if len(self.calls) > 1 else 0,
|
210
|
+
"current_utilization": (calls_after / self.max_calls) * 100,
|
211
|
+
}
|
212
|
+
|
213
|
+
self.contextual_logger.debug(
|
214
|
+
"Cleared %d expired calls, %d calls remaining (%.1f%% capacity)",
|
215
|
+
expired_calls,
|
216
|
+
calls_after,
|
217
|
+
clear_context["current_utilization"],
|
218
|
+
context=clear_context,
|
219
|
+
)
|
220
|
+
|
63
221
|
@property
|
64
222
|
def _timespan(self) -> float:
|
223
|
+
"""Get the time span of the current sliding window"""
|
224
|
+
if len(self.calls) < 2:
|
225
|
+
return 0
|
65
226
|
return self.calls[-1] - self.calls[0]
|
227
|
+
|
228
|
+
def get_rate_limit_stats(self) -> dict[str, Any]:
|
229
|
+
"""Get comprehensive rate limiting statistics"""
|
230
|
+
time.time()
|
231
|
+
|
232
|
+
# Calculate current rate
|
233
|
+
current_rate = 0.0
|
234
|
+
if len(self.calls) > 1:
|
235
|
+
window_duration = min(self._timespan, self.period)
|
236
|
+
if window_duration > 0:
|
237
|
+
current_rate = len(self.calls) / window_duration
|
238
|
+
|
239
|
+
stats = self.stats.copy()
|
240
|
+
stats.update(
|
241
|
+
{
|
242
|
+
"current_calls_in_window": len(self.calls),
|
243
|
+
"current_window_timespan": self._timespan,
|
244
|
+
"current_rate_per_second": current_rate,
|
245
|
+
"configured_max_rate": self.max_calls / self.period,
|
246
|
+
"capacity_utilization_percent": (len(self.calls) / self.max_calls) * 100,
|
247
|
+
"next_sleep_time": self.get_sleep_time(),
|
248
|
+
"is_rate_limited": len(self.calls) >= self.max_calls,
|
249
|
+
"efficiency_ratio": (self.stats["total_requests"] - self.stats["blocked_requests"])
|
250
|
+
/ max(1, self.stats["total_requests"]),
|
251
|
+
}
|
252
|
+
)
|
253
|
+
|
254
|
+
return stats
|
255
|
+
|
256
|
+
def log_stats(self, operation: str = "rate_limit_stats"):
|
257
|
+
"""Log current rate limiting statistics"""
|
258
|
+
stats = self.get_rate_limit_stats()
|
259
|
+
|
260
|
+
stats_context = {"operation": operation, **stats}
|
261
|
+
|
262
|
+
self.contextual_logger.info(
|
263
|
+
"Rate limiter stats: %d/%d requests, %.1f%% blocked, %.3fs avg wait",
|
264
|
+
stats["total_requests"],
|
265
|
+
stats["max_calls"],
|
266
|
+
(stats["blocked_requests"] / max(1, stats["total_requests"])) * 100,
|
267
|
+
stats["average_wait_time"],
|
268
|
+
context=stats_context,
|
269
|
+
)
|