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/__init__.py +215 -0
- dataquery/auth.py +380 -0
- dataquery/auto_download.py +448 -0
- dataquery/cli.py +244 -0
- dataquery/client.py +2825 -0
- dataquery/config.py +571 -0
- dataquery/connection_pool.py +538 -0
- dataquery/dataquery.py +2467 -0
- dataquery/exceptions.py +169 -0
- dataquery/logging_config.py +415 -0
- dataquery/models.py +1303 -0
- dataquery/py.typed +2 -0
- dataquery/rate_limiter.py +520 -0
- dataquery/retry.py +406 -0
- dataquery/utils.py +525 -0
- dataquery_sdk-0.0.7.dist-info/METADATA +996 -0
- dataquery_sdk-0.0.7.dist-info/RECORD +21 -0
- dataquery_sdk-0.0.7.dist-info/WHEEL +5 -0
- dataquery_sdk-0.0.7.dist-info/entry_points.txt +2 -0
- dataquery_sdk-0.0.7.dist-info/licenses/LICENSE +201 -0
- dataquery_sdk-0.0.7.dist-info/top_level.txt +1 -0
dataquery/retry.py
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Enhanced retry logic for the DATAQUERY SDK.
|
|
3
|
+
|
|
4
|
+
Provides exponential backoff, jitter, circuit breaker pattern,
|
|
5
|
+
and configurable retry strategies.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import random
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import datetime, timedelta
|
|
13
|
+
from enum import Enum
|
|
14
|
+
from typing import Any, Callable, Dict, List, Optional, Type
|
|
15
|
+
|
|
16
|
+
import structlog
|
|
17
|
+
|
|
18
|
+
from .exceptions import NetworkError
|
|
19
|
+
|
|
20
|
+
logger = structlog.get_logger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RetryStrategy(str, Enum):
|
|
24
|
+
"""Retry strategies."""
|
|
25
|
+
|
|
26
|
+
EXPONENTIAL = "exponential"
|
|
27
|
+
LINEAR = "linear"
|
|
28
|
+
CONSTANT = "constant"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CircuitState(str, Enum):
|
|
32
|
+
"""Circuit breaker states."""
|
|
33
|
+
|
|
34
|
+
CLOSED = "closed" # Normal operation
|
|
35
|
+
OPEN = "open" # Failing, reject requests
|
|
36
|
+
HALF_OPEN = "half_open" # Testing if service recovered
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class RetryConfig:
|
|
41
|
+
"""Configuration for retry logic."""
|
|
42
|
+
|
|
43
|
+
max_retries: int = 3
|
|
44
|
+
base_delay: float = 1.0
|
|
45
|
+
max_delay: float = 300.0
|
|
46
|
+
exponential_base: float = 2.0
|
|
47
|
+
jitter: bool = True
|
|
48
|
+
jitter_factor: float = 0.1
|
|
49
|
+
strategy: RetryStrategy = RetryStrategy.EXPONENTIAL
|
|
50
|
+
retryable_exceptions: List[Type[Exception]] = field(default_factory=list)
|
|
51
|
+
non_retryable_exceptions: List[Type[Exception]] = field(default_factory=list)
|
|
52
|
+
timeout: Optional[float] = None
|
|
53
|
+
enable_circuit_breaker: bool = True
|
|
54
|
+
circuit_breaker_threshold: int = 5
|
|
55
|
+
circuit_breaker_timeout: float = 300.0
|
|
56
|
+
circuit_breaker_success_threshold: int = 2
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class RetryStats:
|
|
61
|
+
"""Retry statistics."""
|
|
62
|
+
|
|
63
|
+
total_attempts: int = 0
|
|
64
|
+
successful_attempts: int = 0
|
|
65
|
+
failed_attempts: int = 0
|
|
66
|
+
retry_count: int = 0
|
|
67
|
+
total_retry_time: float = 0.0
|
|
68
|
+
last_retry_time: Optional[datetime] = None
|
|
69
|
+
circuit_breaker_trips: int = 0
|
|
70
|
+
circuit_breaker_resets: int = 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class CircuitBreaker:
|
|
74
|
+
"""Circuit breaker implementation."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, config: RetryConfig):
|
|
77
|
+
self.config = config
|
|
78
|
+
self.state = CircuitState.CLOSED
|
|
79
|
+
self.failure_count = 0
|
|
80
|
+
self.last_failure_time: Optional[datetime] = None
|
|
81
|
+
self.success_count = 0
|
|
82
|
+
self.last_state_change = datetime.now()
|
|
83
|
+
|
|
84
|
+
logger.info(
|
|
85
|
+
"Circuit breaker initialized",
|
|
86
|
+
threshold=config.circuit_breaker_threshold,
|
|
87
|
+
timeout=config.circuit_breaker_timeout,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def record_success(self) -> None:
|
|
91
|
+
"""Record a successful operation."""
|
|
92
|
+
if self.state == CircuitState.HALF_OPEN:
|
|
93
|
+
self.success_count += 1
|
|
94
|
+
if self.success_count >= self.config.circuit_breaker_success_threshold:
|
|
95
|
+
self._close_circuit()
|
|
96
|
+
else:
|
|
97
|
+
self.failure_count = 0
|
|
98
|
+
|
|
99
|
+
def record_failure(self) -> None:
|
|
100
|
+
"""Record a failed operation."""
|
|
101
|
+
self.failure_count += 1
|
|
102
|
+
self.last_failure_time = datetime.now()
|
|
103
|
+
|
|
104
|
+
if (
|
|
105
|
+
self.state == CircuitState.CLOSED
|
|
106
|
+
and self.failure_count >= self.config.circuit_breaker_threshold
|
|
107
|
+
):
|
|
108
|
+
self._open_circuit()
|
|
109
|
+
elif self.state == CircuitState.HALF_OPEN:
|
|
110
|
+
self._open_circuit()
|
|
111
|
+
|
|
112
|
+
def can_execute(self) -> bool:
|
|
113
|
+
"""Check if operation can be executed."""
|
|
114
|
+
if self.state == CircuitState.CLOSED:
|
|
115
|
+
return True
|
|
116
|
+
|
|
117
|
+
if self.state == CircuitState.OPEN:
|
|
118
|
+
# Check if timeout has passed
|
|
119
|
+
if (
|
|
120
|
+
self.last_failure_time
|
|
121
|
+
and datetime.now() - self.last_failure_time
|
|
122
|
+
>= timedelta(seconds=self.config.circuit_breaker_timeout)
|
|
123
|
+
):
|
|
124
|
+
self._half_open_circuit()
|
|
125
|
+
return True
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
# HALF_OPEN state
|
|
129
|
+
return True
|
|
130
|
+
|
|
131
|
+
def _open_circuit(self) -> None:
|
|
132
|
+
"""Open the circuit breaker."""
|
|
133
|
+
if self.state != CircuitState.OPEN:
|
|
134
|
+
self.state = CircuitState.OPEN
|
|
135
|
+
self.last_state_change = datetime.now()
|
|
136
|
+
self.success_count = 0
|
|
137
|
+
logger.warning(
|
|
138
|
+
"Circuit breaker opened",
|
|
139
|
+
failure_count=self.failure_count,
|
|
140
|
+
threshold=self.config.circuit_breaker_threshold,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def _half_open_circuit(self) -> None:
|
|
144
|
+
"""Set circuit breaker to half-open state."""
|
|
145
|
+
if self.state != CircuitState.HALF_OPEN:
|
|
146
|
+
self.state = CircuitState.HALF_OPEN
|
|
147
|
+
self.last_state_change = datetime.now()
|
|
148
|
+
self.success_count = 0
|
|
149
|
+
logger.info("Circuit breaker half-open")
|
|
150
|
+
|
|
151
|
+
def _close_circuit(self) -> None:
|
|
152
|
+
"""Close the circuit breaker."""
|
|
153
|
+
if self.state != CircuitState.CLOSED:
|
|
154
|
+
self.state = CircuitState.CLOSED
|
|
155
|
+
self.last_state_change = datetime.now()
|
|
156
|
+
self.failure_count = 0
|
|
157
|
+
self.success_count = 0
|
|
158
|
+
logger.info("Circuit breaker closed")
|
|
159
|
+
|
|
160
|
+
def get_stats(self) -> Dict[str, Any]:
|
|
161
|
+
"""Get circuit breaker statistics."""
|
|
162
|
+
return {
|
|
163
|
+
"state": self.state.value,
|
|
164
|
+
"failure_count": self.failure_count,
|
|
165
|
+
"success_count": self.success_count,
|
|
166
|
+
"last_failure_time": (
|
|
167
|
+
self.last_failure_time.isoformat() if self.last_failure_time else None
|
|
168
|
+
),
|
|
169
|
+
"last_state_change": self.last_state_change.isoformat(),
|
|
170
|
+
"threshold": self.config.circuit_breaker_threshold,
|
|
171
|
+
"timeout": self.config.circuit_breaker_timeout,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class RetryManager:
|
|
176
|
+
"""Enhanced retry manager with exponential backoff and circuit breaker."""
|
|
177
|
+
|
|
178
|
+
def __init__(self, config: RetryConfig):
|
|
179
|
+
self.config = config
|
|
180
|
+
self.stats = RetryStats()
|
|
181
|
+
self.circuit_breaker = (
|
|
182
|
+
CircuitBreaker(config) if config.enable_circuit_breaker else None
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# Default retryable exceptions
|
|
186
|
+
if not config.retryable_exceptions:
|
|
187
|
+
config.retryable_exceptions = [
|
|
188
|
+
ConnectionError,
|
|
189
|
+
TimeoutError,
|
|
190
|
+
asyncio.TimeoutError,
|
|
191
|
+
OSError,
|
|
192
|
+
]
|
|
193
|
+
|
|
194
|
+
logger.info(
|
|
195
|
+
"Retry manager initialized",
|
|
196
|
+
max_retries=config.max_retries,
|
|
197
|
+
strategy=config.strategy.value,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
async def execute_with_retry(self, func: Callable, *args, **kwargs) -> Any:
|
|
201
|
+
"""
|
|
202
|
+
Execute function with retry logic.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
func: Function to execute
|
|
206
|
+
*args: Function arguments
|
|
207
|
+
**kwargs: Function keyword arguments
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
Function result
|
|
211
|
+
|
|
212
|
+
Raises:
|
|
213
|
+
Exception: If all retries fail
|
|
214
|
+
"""
|
|
215
|
+
last_exception = None
|
|
216
|
+
|
|
217
|
+
for attempt in range(self.config.max_retries + 1):
|
|
218
|
+
self.stats.total_attempts += 1
|
|
219
|
+
|
|
220
|
+
# Check circuit breaker
|
|
221
|
+
if self.circuit_breaker and not self.circuit_breaker.can_execute():
|
|
222
|
+
raise NetworkError(
|
|
223
|
+
"Circuit breaker is open - service temporarily unavailable"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
# Execute function
|
|
228
|
+
if asyncio.iscoroutinefunction(func):
|
|
229
|
+
result = await func(*args, **kwargs)
|
|
230
|
+
else:
|
|
231
|
+
result = func(*args, **kwargs)
|
|
232
|
+
|
|
233
|
+
# Record success
|
|
234
|
+
self.stats.successful_attempts += 1
|
|
235
|
+
if self.circuit_breaker:
|
|
236
|
+
self.circuit_breaker.record_success()
|
|
237
|
+
|
|
238
|
+
logger.debug("Operation successful", attempt=attempt + 1)
|
|
239
|
+
return result
|
|
240
|
+
|
|
241
|
+
except Exception as e:
|
|
242
|
+
self.stats.failed_attempts += 1
|
|
243
|
+
last_exception = e
|
|
244
|
+
|
|
245
|
+
# Check if exception is retryable
|
|
246
|
+
if not self._is_retryable_exception(e):
|
|
247
|
+
logger.warning(
|
|
248
|
+
"Non-retryable exception",
|
|
249
|
+
exception_type=type(e).__name__,
|
|
250
|
+
error=str(e),
|
|
251
|
+
)
|
|
252
|
+
raise e
|
|
253
|
+
|
|
254
|
+
# Record failure in circuit breaker
|
|
255
|
+
if self.circuit_breaker:
|
|
256
|
+
self.circuit_breaker.record_failure()
|
|
257
|
+
|
|
258
|
+
# Check if this was the last attempt
|
|
259
|
+
if attempt == self.config.max_retries:
|
|
260
|
+
logger.error(
|
|
261
|
+
"All retry attempts failed",
|
|
262
|
+
total_attempts=self.config.max_retries + 1,
|
|
263
|
+
last_error=str(e),
|
|
264
|
+
)
|
|
265
|
+
break
|
|
266
|
+
|
|
267
|
+
# Calculate delay
|
|
268
|
+
delay = self._calculate_delay(attempt)
|
|
269
|
+
|
|
270
|
+
logger.warning(
|
|
271
|
+
"Operation failed, retrying",
|
|
272
|
+
attempt=attempt + 1,
|
|
273
|
+
max_attempts=self.config.max_retries + 1,
|
|
274
|
+
delay=delay,
|
|
275
|
+
error=str(e),
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
# Wait before retry
|
|
279
|
+
start_time = time.time()
|
|
280
|
+
await asyncio.sleep(delay)
|
|
281
|
+
self.stats.total_retry_time += time.time() - start_time
|
|
282
|
+
self.stats.retry_count += 1
|
|
283
|
+
self.stats.last_retry_time = datetime.now()
|
|
284
|
+
|
|
285
|
+
# All retries failed
|
|
286
|
+
if last_exception is None:
|
|
287
|
+
raise NetworkError(
|
|
288
|
+
"All retry attempts failed - operation could not be completed"
|
|
289
|
+
)
|
|
290
|
+
raise last_exception
|
|
291
|
+
|
|
292
|
+
def _is_retryable_exception(self, exception: Exception) -> bool:
|
|
293
|
+
"""Check if exception is retryable."""
|
|
294
|
+
exception_type = type(exception)
|
|
295
|
+
|
|
296
|
+
# Check non-retryable exceptions first
|
|
297
|
+
for non_retryable in self.config.non_retryable_exceptions:
|
|
298
|
+
if issubclass(exception_type, non_retryable):
|
|
299
|
+
return False
|
|
300
|
+
|
|
301
|
+
# Check retryable exceptions
|
|
302
|
+
for retryable in self.config.retryable_exceptions:
|
|
303
|
+
if issubclass(exception_type, retryable):
|
|
304
|
+
return True
|
|
305
|
+
|
|
306
|
+
# Default: retry on any exception if no specific lists provided
|
|
307
|
+
return len(self.config.retryable_exceptions) == 0
|
|
308
|
+
|
|
309
|
+
def _calculate_delay(self, attempt: int) -> float:
|
|
310
|
+
"""Calculate delay for retry attempt with optimized performance."""
|
|
311
|
+
if self.config.strategy == RetryStrategy.EXPONENTIAL:
|
|
312
|
+
# Use bit shifting for faster exponentiation when possible
|
|
313
|
+
if self.config.exponential_base == 2.0:
|
|
314
|
+
delay = self.config.base_delay * (1 << min(attempt, 10)) # Cap at 2^10
|
|
315
|
+
else:
|
|
316
|
+
delay = self.config.base_delay * (self.config.exponential_base**attempt)
|
|
317
|
+
elif self.config.strategy == RetryStrategy.LINEAR:
|
|
318
|
+
delay = self.config.base_delay * (attempt + 1)
|
|
319
|
+
else: # CONSTANT
|
|
320
|
+
delay = self.config.base_delay
|
|
321
|
+
|
|
322
|
+
# Apply jitter more efficiently
|
|
323
|
+
if self.config.jitter and self.config.jitter_factor > 0:
|
|
324
|
+
# Use faster random generation
|
|
325
|
+
jitter = random.random() * self.config.jitter_factor * delay
|
|
326
|
+
delay += jitter
|
|
327
|
+
|
|
328
|
+
# Cap at maximum delay
|
|
329
|
+
return min(delay, self.config.max_delay)
|
|
330
|
+
|
|
331
|
+
def get_stats(self) -> Dict[str, Any]:
|
|
332
|
+
"""Get retry statistics."""
|
|
333
|
+
stats = {
|
|
334
|
+
"config": {
|
|
335
|
+
"max_retries": self.config.max_retries,
|
|
336
|
+
"strategy": self.config.strategy.value,
|
|
337
|
+
"base_delay": self.config.base_delay,
|
|
338
|
+
"max_delay": self.config.max_delay,
|
|
339
|
+
},
|
|
340
|
+
"stats": {
|
|
341
|
+
"total_attempts": self.stats.total_attempts,
|
|
342
|
+
"successful_attempts": self.stats.successful_attempts,
|
|
343
|
+
"failed_attempts": self.stats.failed_attempts,
|
|
344
|
+
"retry_count": self.stats.retry_count,
|
|
345
|
+
"total_retry_time": self.stats.total_retry_time,
|
|
346
|
+
"last_retry_time": (
|
|
347
|
+
self.stats.last_retry_time.isoformat()
|
|
348
|
+
if self.stats.last_retry_time
|
|
349
|
+
else None
|
|
350
|
+
),
|
|
351
|
+
},
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if self.circuit_breaker:
|
|
355
|
+
stats["circuit_breaker"] = self.circuit_breaker.get_stats()
|
|
356
|
+
|
|
357
|
+
return stats
|
|
358
|
+
|
|
359
|
+
def reset_stats(self) -> None:
|
|
360
|
+
"""Reset retry statistics."""
|
|
361
|
+
self.stats = RetryStats()
|
|
362
|
+
if self.circuit_breaker:
|
|
363
|
+
self.circuit_breaker = CircuitBreaker(self.config)
|
|
364
|
+
logger.info("Retry statistics reset")
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def create_retry_config(
|
|
368
|
+
max_retries: int = 3,
|
|
369
|
+
base_delay: float = 1.0,
|
|
370
|
+
max_delay: float = 300.0,
|
|
371
|
+
strategy: RetryStrategy = RetryStrategy.EXPONENTIAL,
|
|
372
|
+
enable_circuit_breaker: bool = True,
|
|
373
|
+
) -> RetryConfig:
|
|
374
|
+
"""
|
|
375
|
+
Create retry configuration.
|
|
376
|
+
|
|
377
|
+
Args:
|
|
378
|
+
max_retries: Maximum number of retry attempts
|
|
379
|
+
base_delay: Base delay between retries
|
|
380
|
+
max_delay: Maximum delay between retries
|
|
381
|
+
strategy: Retry strategy to use
|
|
382
|
+
enable_circuit_breaker: Whether to enable circuit breaker
|
|
383
|
+
|
|
384
|
+
Returns:
|
|
385
|
+
Retry configuration
|
|
386
|
+
"""
|
|
387
|
+
return RetryConfig(
|
|
388
|
+
max_retries=max_retries,
|
|
389
|
+
base_delay=base_delay,
|
|
390
|
+
max_delay=max_delay,
|
|
391
|
+
strategy=strategy,
|
|
392
|
+
enable_circuit_breaker=enable_circuit_breaker,
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def create_retry_manager(config: RetryConfig) -> RetryManager:
|
|
397
|
+
"""
|
|
398
|
+
Create a retry manager with the specified configuration.
|
|
399
|
+
|
|
400
|
+
Args:
|
|
401
|
+
config: Retry configuration
|
|
402
|
+
|
|
403
|
+
Returns:
|
|
404
|
+
Configured retry manager
|
|
405
|
+
"""
|
|
406
|
+
return RetryManager(config)
|