dexscreen 0.0.2__py3-none-any.whl → 0.0.5__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.
@@ -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
@@ -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
- return until - time.time()
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
- self.calls.append(time.time())
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
- while self._timespan >= self.period:
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
+ )