thordata-sdk 0.3.1__py3-none-any.whl → 0.4.0__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.
thordata/retry.py ADDED
@@ -0,0 +1,382 @@
1
+ """
2
+ Retry mechanism for the Thordata Python SDK.
3
+
4
+ This module provides configurable retry logic for handling transient failures
5
+ in API requests, with support for exponential backoff and jitter.
6
+
7
+ Example:
8
+ >>> from thordata.retry import RetryConfig, with_retry
9
+ >>>
10
+ >>> config = RetryConfig(max_retries=3, backoff_factor=1.0)
11
+ >>>
12
+ >>> @with_retry(config)
13
+ >>> def make_request():
14
+ ... return requests.get("https://api.example.com")
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import time
20
+ import random
21
+ import logging
22
+ from dataclasses import dataclass, field
23
+ from typing import (
24
+ Callable, TypeVar, Set, Optional, Union, Tuple, Any
25
+ )
26
+ from functools import wraps
27
+
28
+ from .exceptions import (
29
+ ThordataError,
30
+ ThordataNetworkError,
31
+ ThordataServerError,
32
+ ThordataRateLimitError,
33
+ is_retryable_exception,
34
+ )
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ T = TypeVar("T")
39
+
40
+
41
+ @dataclass
42
+ class RetryConfig:
43
+ """
44
+ Configuration for retry behavior.
45
+
46
+ Attributes:
47
+ max_retries: Maximum number of retry attempts (default: 3).
48
+ backoff_factor: Multiplier for exponential backoff (default: 1.0).
49
+ Wait time = backoff_factor * (2 ** attempt_number)
50
+ max_backoff: Maximum wait time in seconds (default: 60).
51
+ jitter: Add random jitter to prevent thundering herd (default: True).
52
+ jitter_factor: Maximum jitter as fraction of wait time (default: 0.1).
53
+ retry_on_status_codes: HTTP status codes to retry on.
54
+ retry_on_exceptions: Exception types to retry on.
55
+
56
+ Example:
57
+ >>> config = RetryConfig(
58
+ ... max_retries=5,
59
+ ... backoff_factor=2.0,
60
+ ... max_backoff=120
61
+ ... )
62
+ """
63
+
64
+ max_retries: int = 3
65
+ backoff_factor: float = 1.0
66
+ max_backoff: float = 60.0
67
+ jitter: bool = True
68
+ jitter_factor: float = 0.1
69
+
70
+ # Status codes to retry on (5xx server errors + 429 rate limit)
71
+ retry_on_status_codes: Set[int] = field(
72
+ default_factory=lambda: {429, 500, 502, 503, 504}
73
+ )
74
+
75
+ # Exception types to always retry on
76
+ retry_on_exceptions: Tuple[type, ...] = field(
77
+ default_factory=lambda: (
78
+ ThordataNetworkError,
79
+ ThordataServerError,
80
+ )
81
+ )
82
+
83
+ def calculate_delay(self, attempt: int) -> float:
84
+ """
85
+ Calculate the delay before the next retry attempt.
86
+
87
+ Args:
88
+ attempt: Current attempt number (0-indexed).
89
+
90
+ Returns:
91
+ Delay in seconds.
92
+ """
93
+ # Exponential backoff
94
+ delay = self.backoff_factor * (2 ** attempt)
95
+
96
+ # Apply maximum cap
97
+ delay = min(delay, self.max_backoff)
98
+
99
+ # Add jitter if enabled
100
+ if self.jitter:
101
+ jitter_range = delay * self.jitter_factor
102
+ delay += random.uniform(-jitter_range, jitter_range)
103
+ delay = max(0.1, delay) # Ensure positive delay
104
+
105
+ return delay
106
+
107
+ def should_retry(
108
+ self,
109
+ exception: Exception,
110
+ attempt: int,
111
+ status_code: Optional[int] = None
112
+ ) -> bool:
113
+ """
114
+ Determine if a request should be retried.
115
+
116
+ Args:
117
+ exception: The exception that was raised.
118
+ attempt: Current attempt number.
119
+ status_code: HTTP status code if available.
120
+
121
+ Returns:
122
+ True if the request should be retried.
123
+ """
124
+ # Check if we've exceeded max retries
125
+ if attempt >= self.max_retries:
126
+ return False
127
+
128
+ # Check status code
129
+ if status_code and status_code in self.retry_on_status_codes:
130
+ return True
131
+
132
+ # Check exception type
133
+ if isinstance(exception, self.retry_on_exceptions):
134
+ return True
135
+
136
+ # Check rate limit with retry_after
137
+ if isinstance(exception, ThordataRateLimitError):
138
+ return True
139
+
140
+ # Use generic retryable check
141
+ return is_retryable_exception(exception)
142
+
143
+
144
+ def with_retry(
145
+ config: Optional[RetryConfig] = None,
146
+ on_retry: Optional[Callable[[int, Exception, float], None]] = None,
147
+ ) -> Callable[[Callable[..., T]], Callable[..., T]]:
148
+ """
149
+ Decorator to add retry logic to a function.
150
+
151
+ Args:
152
+ config: Retry configuration. Uses defaults if not provided.
153
+ on_retry: Optional callback called before each retry.
154
+ Receives (attempt, exception, delay).
155
+
156
+ Returns:
157
+ Decorated function with retry logic.
158
+
159
+ Example:
160
+ >>> @with_retry(RetryConfig(max_retries=3))
161
+ ... def fetch_data():
162
+ ... return requests.get("https://api.example.com")
163
+
164
+ >>> @with_retry()
165
+ ... async def async_fetch():
166
+ ... async with aiohttp.ClientSession() as session:
167
+ ... return await session.get("https://api.example.com")
168
+ """
169
+ if config is None:
170
+ config = RetryConfig()
171
+
172
+ def decorator(func: Callable[..., T]) -> Callable[..., T]:
173
+ @wraps(func)
174
+ def sync_wrapper(*args: Any, **kwargs: Any) -> T:
175
+ last_exception: Optional[Exception] = None
176
+
177
+ for attempt in range(config.max_retries + 1):
178
+ try:
179
+ return func(*args, **kwargs)
180
+ except Exception as e:
181
+ last_exception = e
182
+
183
+ # Extract status code if available
184
+ status_code = _extract_status_code(e)
185
+
186
+ if not config.should_retry(e, attempt, status_code):
187
+ raise
188
+
189
+ delay = config.calculate_delay(attempt)
190
+
191
+ # Handle rate limit retry_after
192
+ if isinstance(e, ThordataRateLimitError) and e.retry_after:
193
+ delay = max(delay, e.retry_after)
194
+
195
+ logger.warning(
196
+ f"Retry attempt {attempt + 1}/{config.max_retries} "
197
+ f"after {delay:.2f}s due to: {e}"
198
+ )
199
+
200
+ if on_retry:
201
+ on_retry(attempt, e, delay)
202
+
203
+ time.sleep(delay)
204
+
205
+ # Should not reach here, but just in case
206
+ raise last_exception # type: ignore
207
+
208
+ @wraps(func)
209
+ async def async_wrapper(*args: Any, **kwargs: Any) -> T:
210
+ import asyncio
211
+
212
+ last_exception: Optional[Exception] = None
213
+
214
+ for attempt in range(config.max_retries + 1):
215
+ try:
216
+ return await func(*args, **kwargs)
217
+ except Exception as e:
218
+ last_exception = e
219
+
220
+ status_code = _extract_status_code(e)
221
+
222
+ if not config.should_retry(e, attempt, status_code):
223
+ raise
224
+
225
+ delay = config.calculate_delay(attempt)
226
+
227
+ if isinstance(e, ThordataRateLimitError) and e.retry_after:
228
+ delay = max(delay, e.retry_after)
229
+
230
+ logger.warning(
231
+ f"Async retry attempt {attempt + 1}/{config.max_retries} "
232
+ f"after {delay:.2f}s due to: {e}"
233
+ )
234
+
235
+ if on_retry:
236
+ on_retry(attempt, e, delay)
237
+
238
+ await asyncio.sleep(delay)
239
+
240
+ raise last_exception # type: ignore
241
+
242
+ # Check if the function is async
243
+ import asyncio
244
+ if asyncio.iscoroutinefunction(func):
245
+ return async_wrapper # type: ignore
246
+ return sync_wrapper
247
+
248
+ return decorator
249
+
250
+
251
+ def _extract_status_code(exception: Exception) -> Optional[int]:
252
+ """
253
+ Extract HTTP status code from various exception types.
254
+
255
+ Args:
256
+ exception: The exception to extract from.
257
+
258
+ Returns:
259
+ HTTP status code if found, None otherwise.
260
+ """
261
+ # Check Thordata exceptions
262
+ if hasattr(exception, "status_code"):
263
+ return exception.status_code
264
+ if hasattr(exception, "code"):
265
+ return exception.code
266
+
267
+ # Check requests exceptions
268
+ if hasattr(exception, "response"):
269
+ response = exception.response
270
+ if response is not None and hasattr(response, "status_code"):
271
+ return response.status_code
272
+
273
+ # Check aiohttp exceptions
274
+ if hasattr(exception, "status"):
275
+ return exception.status
276
+
277
+ return None
278
+
279
+
280
+ class RetryableRequest:
281
+ """
282
+ Context manager for retryable requests with detailed control.
283
+
284
+ This provides more control than the decorator approach, allowing
285
+ you to check retry status during execution.
286
+
287
+ Example:
288
+ >>> config = RetryConfig(max_retries=3)
289
+ >>> with RetryableRequest(config) as retry:
290
+ ... while True:
291
+ ... try:
292
+ ... response = requests.get("https://api.example.com")
293
+ ... response.raise_for_status()
294
+ ... break
295
+ ... except Exception as e:
296
+ ... if not retry.should_continue(e):
297
+ ... raise
298
+ ... retry.wait()
299
+ """
300
+
301
+ def __init__(self, config: Optional[RetryConfig] = None) -> None:
302
+ self.config = config or RetryConfig()
303
+ self.attempt = 0
304
+ self.last_exception: Optional[Exception] = None
305
+
306
+ def __enter__(self) -> "RetryableRequest":
307
+ return self
308
+
309
+ def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
310
+ return False
311
+
312
+ def should_continue(
313
+ self,
314
+ exception: Exception,
315
+ status_code: Optional[int] = None
316
+ ) -> bool:
317
+ """
318
+ Check if we should continue retrying.
319
+
320
+ Args:
321
+ exception: The exception that occurred.
322
+ status_code: HTTP status code if available.
323
+
324
+ Returns:
325
+ True if we should retry, False otherwise.
326
+ """
327
+ self.last_exception = exception
328
+
329
+ if status_code is None:
330
+ status_code = _extract_status_code(exception)
331
+
332
+ should_retry = self.config.should_retry(
333
+ exception, self.attempt, status_code
334
+ )
335
+
336
+ if should_retry:
337
+ self.attempt += 1
338
+
339
+ return should_retry
340
+
341
+ def wait(self) -> float:
342
+ """
343
+ Wait before the next retry attempt.
344
+
345
+ Returns:
346
+ The actual delay used.
347
+ """
348
+ delay = self.config.calculate_delay(self.attempt - 1)
349
+
350
+ # Handle rate limit retry_after
351
+ if (
352
+ isinstance(self.last_exception, ThordataRateLimitError)
353
+ and self.last_exception.retry_after
354
+ ):
355
+ delay = max(delay, self.last_exception.retry_after)
356
+
357
+ logger.debug(f"Waiting {delay:.2f}s before retry {self.attempt}")
358
+ time.sleep(delay)
359
+
360
+ return delay
361
+
362
+ async def async_wait(self) -> float:
363
+ """
364
+ Async version of wait().
365
+
366
+ Returns:
367
+ The actual delay used.
368
+ """
369
+ import asyncio
370
+
371
+ delay = self.config.calculate_delay(self.attempt - 1)
372
+
373
+ if (
374
+ isinstance(self.last_exception, ThordataRateLimitError)
375
+ and self.last_exception.retry_after
376
+ ):
377
+ delay = max(delay, self.last_exception.retry_after)
378
+
379
+ logger.debug(f"Async waiting {delay:.2f}s before retry {self.attempt}")
380
+ await asyncio.sleep(delay)
381
+
382
+ return delay