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/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)