dataquery-sdk 0.0.7__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.
dataquery/py.typed ADDED
@@ -0,0 +1,2 @@
1
+
2
+
@@ -0,0 +1,520 @@
1
+ """
2
+ Enhanced rate limiting implementation for the DATAQUERY SDK.
3
+
4
+ Provides token bucket rate limiting with configurable burst capacity,
5
+ requests per minute limits, and a queuing mechanism to prevent breaches.
6
+ """
7
+
8
+ import asyncio
9
+ import time
10
+ from collections import deque
11
+ from dataclasses import dataclass, field
12
+ from enum import Enum
13
+ from typing import Any, Dict, Optional
14
+
15
+ import structlog
16
+
17
+ logger = structlog.get_logger(__name__)
18
+
19
+
20
+ class QueuePriority(Enum):
21
+ """Priority levels for queued requests."""
22
+
23
+ LOW = 1
24
+ NORMAL = 2
25
+ HIGH = 3
26
+ CRITICAL = 4
27
+
28
+ def __lt__(self, other):
29
+ """Enable comparison of priorities."""
30
+ if isinstance(other, QueuePriority):
31
+ return self.value < other.value
32
+ return NotImplemented
33
+
34
+ def __le__(self, other):
35
+ """Enable comparison of priorities."""
36
+ if isinstance(other, QueuePriority):
37
+ return self.value <= other.value
38
+ return NotImplemented
39
+
40
+ def __gt__(self, other):
41
+ """Enable comparison of priorities."""
42
+ if isinstance(other, QueuePriority):
43
+ return self.value > other.value
44
+ return NotImplemented
45
+
46
+ def __ge__(self, other):
47
+ """Enable comparison of priorities."""
48
+ if isinstance(other, QueuePriority):
49
+ return self.value >= other.value
50
+ return NotImplemented
51
+
52
+
53
+ @dataclass
54
+ class QueuedRequest:
55
+ """Represents a queued request."""
56
+
57
+ priority: QueuePriority
58
+ timestamp: float
59
+ request_id: str
60
+ operation: str
61
+ future: asyncio.Future
62
+ timeout: Optional[float] = None
63
+
64
+
65
+ @dataclass
66
+ class RateLimitConfig:
67
+ """Configuration for rate limiting."""
68
+
69
+ requests_per_minute: int = 300 # Exactly 5 requests per second (200ms per request)
70
+ burst_capacity: int = 1 # No burst allowed per specification
71
+ window_size_seconds: int = 60
72
+ retry_after_header: str = "Retry-After"
73
+ enable_rate_limiting: bool = True
74
+
75
+ # Queue configuration
76
+ max_queue_size: int = 1000
77
+ enable_queuing: bool = True
78
+ queue_timeout: float = 600.0 # 10 minutes
79
+ priority_enabled: bool = True
80
+
81
+ # Adaptive rate limiting
82
+ adaptive_rate_limiting: bool = True
83
+ backoff_multiplier: float = 1.5
84
+ max_backoff_seconds: float = 60.0
85
+
86
+ def __post_init__(self):
87
+ """Validate configuration."""
88
+ if self.requests_per_minute <= 0:
89
+ raise ValueError("requests_per_minute must be positive")
90
+ if self.burst_capacity <= 0:
91
+ raise ValueError("burst_capacity must be positive")
92
+ if self.window_size_seconds <= 0:
93
+ raise ValueError("window_size_seconds must be positive")
94
+ if self.burst_capacity > self.requests_per_minute:
95
+ raise ValueError("burst_capacity cannot exceed requests_per_minute")
96
+ if self.max_queue_size <= 0:
97
+ raise ValueError("max_queue_size must be positive")
98
+ if self.queue_timeout <= 0:
99
+ raise ValueError("queue_timeout must be positive")
100
+
101
+
102
+ @dataclass
103
+ class RateLimitState:
104
+ """Internal state for rate limiting."""
105
+
106
+ tokens: float = field(default=0.0)
107
+ last_refill: float = field(default_factory=time.time)
108
+ request_count: int = 0
109
+ window_start: float = field(default_factory=time.time)
110
+ retry_after: Optional[float] = None
111
+
112
+ # Queue state
113
+ queue: deque = field(default_factory=deque)
114
+ processing: bool = False
115
+ last_request_time: float = field(default_factory=time.time)
116
+
117
+ # Adaptive rate limiting
118
+ consecutive_failures: int = 0
119
+ current_backoff: float = 0.0
120
+
121
+ # Request statistics
122
+ total_requests: int = 0
123
+ successful_requests: int = 0
124
+ failed_requests: int = 0
125
+ rate_limited_requests: int = 0
126
+ last_failure_time: float = 0.0
127
+
128
+
129
+ class EnhancedTokenBucketRateLimiter:
130
+ """Enhanced token bucket rate limiter with queuing mechanism."""
131
+
132
+ def __init__(self, config: RateLimitConfig):
133
+ self.config = config
134
+ self.state = RateLimitState()
135
+ self._lock: Optional[asyncio.Lock] = None
136
+ self._queue_processor_task: Optional[asyncio.Task] = None
137
+ self._shutdown_event: Optional[asyncio.Event] = None
138
+ self._queue_processor_started = False
139
+
140
+ # Initialize tokens to burst capacity
141
+ self.state.tokens = float(config.burst_capacity)
142
+ self.state.last_refill = time.time()
143
+
144
+ logger.info(
145
+ "Enhanced rate limiter initialized",
146
+ requests_per_minute=config.requests_per_minute,
147
+ burst_capacity=config.burst_capacity,
148
+ queuing_enabled=config.enable_queuing,
149
+ max_queue_size=config.max_queue_size,
150
+ )
151
+
152
+ def _get_lock(self) -> asyncio.Lock:
153
+ """Get the asyncio lock, creating it if necessary."""
154
+ if self._lock is None:
155
+ self._lock = asyncio.Lock()
156
+ return self._lock
157
+
158
+ def _get_shutdown_event(self) -> asyncio.Event:
159
+ """Get the shutdown event, creating it if necessary."""
160
+ if self._shutdown_event is None:
161
+ self._shutdown_event = asyncio.Event()
162
+ return self._shutdown_event
163
+
164
+ def _start_queue_processor(self):
165
+ """Start the background queue processor."""
166
+ if self._queue_processor_task is None or self._queue_processor_task.done():
167
+ try:
168
+ # Check if there's a running event loop
169
+ loop = asyncio.get_running_loop()
170
+ self._queue_processor_task = loop.create_task(self._process_queue())
171
+ self._queue_processor_started = True
172
+ except RuntimeError:
173
+ # No running event loop, will start when acquire is called
174
+ self._queue_processor_started = False
175
+
176
+ async def _process_queue(self):
177
+ """Background task to process queued requests."""
178
+ while not self._get_shutdown_event().is_set():
179
+ try:
180
+ async with self._get_lock():
181
+ if not self.state.queue:
182
+ await asyncio.sleep(0.1)
183
+ continue
184
+
185
+ # Get next request from queue (priority-based)
186
+ request = self._get_next_request()
187
+ if request is None:
188
+ await asyncio.sleep(0.1)
189
+ continue
190
+
191
+ # Check if request has timed out
192
+ if (
193
+ request.timeout
194
+ and time.time() - request.timestamp > request.timeout
195
+ ):
196
+ if not request.future.done():
197
+ request.future.set_exception(
198
+ asyncio.TimeoutError("Request timed out in queue")
199
+ )
200
+ continue
201
+
202
+ # Try to acquire token
203
+ if await self._try_acquire_token():
204
+ # Remove from queue and resolve future
205
+ self.state.queue.remove(request)
206
+ if not request.future.done():
207
+ request.future.set_result(True)
208
+ else:
209
+ # Put back at front of queue and wait
210
+ await asyncio.sleep(self._calculate_wait_time())
211
+
212
+ except Exception as e:
213
+ logger.error("Error in queue processor", error=str(e))
214
+ await asyncio.sleep(1.0)
215
+
216
+ def _get_next_request(self) -> Optional[QueuedRequest]:
217
+ """Get the next request from queue based on priority."""
218
+ if not self.state.queue:
219
+ return None
220
+
221
+ if not self.config.priority_enabled:
222
+ return self.state.queue[0]
223
+
224
+ # Find highest priority request
225
+ highest_priority = max(req.priority.value for req in self.state.queue)
226
+ for request in self.state.queue:
227
+ if request.priority.value == highest_priority:
228
+ return request
229
+
230
+ return None
231
+
232
+ async def _try_acquire_token(self) -> bool:
233
+ """Try to acquire a token without waiting."""
234
+ self._refill_tokens()
235
+
236
+ if self.state.tokens >= 1.0:
237
+ self.state.tokens -= 1.0
238
+ self.state.request_count += 1
239
+ self.state.last_request_time = time.time()
240
+ return True
241
+
242
+ return False
243
+
244
+ async def acquire(
245
+ self,
246
+ timeout: Optional[float] = None,
247
+ priority: QueuePriority = QueuePriority.NORMAL,
248
+ operation: str = "unknown",
249
+ ) -> bool:
250
+ """
251
+ Acquire a token for making a request with optimized performance.
252
+
253
+ Args:
254
+ timeout: Maximum time to wait for a token (None = no timeout)
255
+ priority: Priority level for queuing (ignored for performance)
256
+ operation: Operation name for logging
257
+
258
+ Returns:
259
+ True if token acquired, False if timeout
260
+ """
261
+ if not self.config.enable_rate_limiting:
262
+ return True
263
+
264
+ # Use optimized simple acquisition for better performance
265
+ return await self._optimized_acquire(timeout, operation)
266
+
267
+ async def _optimized_acquire(
268
+ self, timeout: Optional[float] = None, operation: str = "unknown"
269
+ ) -> bool:
270
+ """Optimized token acquisition with minimal overhead."""
271
+ start_time = time.time()
272
+
273
+ async with self._get_lock():
274
+ while True:
275
+ self._refill_tokens()
276
+
277
+ if self.state.tokens >= 1.0:
278
+ self.state.tokens -= 1.0
279
+ self.state.request_count += 1
280
+ self.state.last_request_time = time.time()
281
+ return True
282
+
283
+ # Calculate wait time more efficiently
284
+ wait_time = self._calculate_wait_time()
285
+
286
+ if timeout is not None:
287
+ elapsed = time.time() - start_time
288
+ if elapsed + wait_time > timeout:
289
+ return False
290
+
291
+ # Use shorter sleep intervals for better responsiveness
292
+ await asyncio.sleep(min(wait_time, 0.1))
293
+
294
+ async def _simple_acquire(self, timeout: Optional[float] = None) -> bool:
295
+ """Simple token bucket acquisition without queuing."""
296
+ start_time = time.time()
297
+
298
+ async with self._get_lock():
299
+ while True:
300
+ self._refill_tokens()
301
+
302
+ if self.state.tokens >= 1.0:
303
+ self.state.tokens -= 1.0
304
+ self.state.request_count += 1
305
+ return True
306
+
307
+ wait_time = self._calculate_wait_time()
308
+
309
+ if timeout is not None:
310
+ elapsed = time.time() - start_time
311
+ if elapsed + wait_time > timeout:
312
+ return False
313
+
314
+ await asyncio.sleep(wait_time)
315
+
316
+ def _refill_tokens(self):
317
+ """Refill tokens based on time elapsed."""
318
+ now = time.time()
319
+ time_elapsed = now - self.state.last_refill
320
+
321
+ # Apply adaptive backoff if needed
322
+ if self.config.adaptive_rate_limiting and self.state.current_backoff > 0:
323
+ time_elapsed = min(time_elapsed, self.state.current_backoff)
324
+
325
+ # Calculate tokens to add
326
+ tokens_per_second = self.config.requests_per_minute / 60.0
327
+ tokens_to_add = time_elapsed * tokens_per_second
328
+
329
+ # Add tokens up to burst capacity
330
+ self.state.tokens = min(
331
+ self.config.burst_capacity, self.state.tokens + tokens_to_add
332
+ )
333
+
334
+ self.state.last_refill = now
335
+
336
+ # Reset window if needed
337
+ if now - self.state.window_start >= self.config.window_size_seconds:
338
+ self.state.request_count = 0
339
+ self.state.window_start = now
340
+
341
+ def _calculate_wait_time(self) -> float:
342
+ """Calculate time to wait for next token."""
343
+ tokens_per_second = self.config.requests_per_minute / 60.0
344
+ if tokens_per_second <= 0:
345
+ return 1.0
346
+
347
+ base_wait = 1.0 / tokens_per_second
348
+
349
+ # Enforce minimum 200ms delay per DataQuery API specification
350
+ min_wait = 0.2 # 200 milliseconds
351
+ base_wait = max(base_wait, min_wait)
352
+
353
+ # Apply adaptive backoff if needed
354
+ if self.config.adaptive_rate_limiting and self.state.current_backoff > 0:
355
+ return max(base_wait, self.state.current_backoff)
356
+
357
+ return base_wait
358
+
359
+ def handle_rate_limit_response(self, headers: Dict[str, str]) -> None:
360
+ """
361
+ Handle rate limit response from server with adaptive backoff.
362
+
363
+ Args:
364
+ headers: Response headers from the server
365
+ """
366
+ retry_after = headers.get(self.config.retry_after_header)
367
+ if retry_after:
368
+ try:
369
+ self.state.retry_after = float(retry_after)
370
+ self.state.consecutive_failures += 1
371
+ self.state.last_failure_time = time.time()
372
+
373
+ # Apply adaptive backoff
374
+ if self.config.adaptive_rate_limiting:
375
+ self.state.current_backoff = min(
376
+ self.config.max_backoff_seconds,
377
+ self.state.current_backoff * self.config.backoff_multiplier,
378
+ )
379
+
380
+ logger.warning(
381
+ "Server rate limit hit",
382
+ retry_after=retry_after,
383
+ consecutive_failures=self.state.consecutive_failures,
384
+ current_backoff=self.state.current_backoff,
385
+ )
386
+ except (ValueError, TypeError):
387
+ logger.warning("Invalid Retry-After header", retry_after=retry_after)
388
+
389
+ def handle_successful_request(self) -> None:
390
+ """Handle successful request to reset adaptive backoff."""
391
+ if self.config.adaptive_rate_limiting and self.state.consecutive_failures > 0:
392
+ self.state.consecutive_failures = 0
393
+ self.state.current_backoff = 0.0
394
+ logger.debug("Rate limit backoff reset after successful request")
395
+
396
+ def get_stats(self) -> Dict[str, Any]:
397
+ """Get current rate limiting statistics."""
398
+ return {
399
+ "tokens_available": self.state.tokens,
400
+ "burst_capacity": self.config.burst_capacity,
401
+ "requests_per_minute": self.config.requests_per_minute,
402
+ "request_count": self.state.request_count,
403
+ "window_start": self.state.window_start,
404
+ "retry_after": self.state.retry_after,
405
+ "last_refill": self.state.last_refill,
406
+ "queue_size": len(self.state.queue),
407
+ "max_queue_size": self.config.max_queue_size,
408
+ "consecutive_failures": self.state.consecutive_failures,
409
+ "current_backoff": self.state.current_backoff,
410
+ "last_request_time": self.state.last_request_time,
411
+ }
412
+
413
+ def reset(self) -> None:
414
+ """Reset rate limiter state."""
415
+
416
+ async def _reset():
417
+ async with self._get_lock():
418
+ self.state = RateLimitState()
419
+ self.state.tokens = float(self.config.burst_capacity)
420
+ self.state.last_refill = time.time()
421
+
422
+ # Clear queue
423
+ for request in self.state.queue:
424
+ if not request.future.done():
425
+ request.future.cancel()
426
+ self.state.queue.clear()
427
+
428
+ # Schedule reset
429
+ asyncio.create_task(_reset())
430
+ logger.info("Rate limiter reset")
431
+
432
+ async def shutdown(self) -> None:
433
+ """Shutdown the rate limiter and clear queue."""
434
+ self._get_shutdown_event().set()
435
+
436
+ if self._queue_processor_task:
437
+ self._queue_processor_task.cancel()
438
+ try:
439
+ await self._queue_processor_task
440
+ except asyncio.CancelledError:
441
+ pass
442
+
443
+ # Clear queue
444
+ async with self._get_lock():
445
+ for request in self.state.queue:
446
+ if not request.future.done():
447
+ request.future.cancel()
448
+ self.state.queue.clear()
449
+
450
+ logger.info("Rate limiter shutdown complete")
451
+
452
+
453
+ class RateLimitContext:
454
+ """Context manager for rate limiting."""
455
+
456
+ def __init__(
457
+ self,
458
+ rate_limiter: EnhancedTokenBucketRateLimiter,
459
+ timeout: Optional[float] = None,
460
+ priority: QueuePriority = QueuePriority.NORMAL,
461
+ operation: str = "unknown",
462
+ ):
463
+ self.rate_limiter = rate_limiter
464
+ self.timeout = timeout
465
+ self.priority = priority
466
+ self.operation = operation
467
+ self.acquired = False
468
+
469
+ async def __aenter__(self):
470
+ """Enter rate limit context."""
471
+ self.acquired = await self.rate_limiter.acquire(
472
+ self.timeout, self.priority, self.operation
473
+ )
474
+ if not self.acquired:
475
+ raise TimeoutError(f"Rate limit timeout for operation: {self.operation}")
476
+ return self
477
+
478
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
479
+ """Exit rate limit context."""
480
+ # Mark successful request if no exception occurred
481
+ if exc_type is None:
482
+ self.rate_limiter.handle_successful_request()
483
+
484
+
485
+ def create_rate_limiter(
486
+ requests_per_minute: int = 300, # Exactly 5 requests per second (200ms per request)
487
+ burst_capacity: int = 1, # No burst allowed per specification
488
+ enable_rate_limiting: bool = True,
489
+ enable_queuing: bool = True,
490
+ max_queue_size: int = 1000,
491
+ adaptive_rate_limiting: bool = True,
492
+ ) -> EnhancedTokenBucketRateLimiter:
493
+ """
494
+ Create an enhanced rate limiter with the specified configuration.
495
+
496
+ Args:
497
+ requests_per_minute: Maximum requests per minute
498
+ burst_capacity: Maximum burst capacity
499
+ enable_rate_limiting: Whether to enable rate limiting
500
+ enable_queuing: Whether to enable request queuing
501
+ max_queue_size: Maximum number of queued requests
502
+ adaptive_rate_limiting: Whether to enable adaptive backoff
503
+
504
+ Returns:
505
+ Configured enhanced rate limiter
506
+ """
507
+ config = RateLimitConfig(
508
+ requests_per_minute=requests_per_minute,
509
+ burst_capacity=burst_capacity,
510
+ enable_rate_limiting=enable_rate_limiting,
511
+ enable_queuing=enable_queuing,
512
+ max_queue_size=max_queue_size,
513
+ adaptive_rate_limiting=adaptive_rate_limiting,
514
+ )
515
+
516
+ return EnhancedTokenBucketRateLimiter(config)
517
+
518
+
519
+ # Backward compatibility
520
+ TokenBucketRateLimiter = EnhancedTokenBucketRateLimiter