Coverage for src/taipanstack/resilience/retry.py: 100%
309 statements
« prev ^ index » next coverage.py v7.15.1, created at 2026-10-13 20:52 +0000
« prev ^ index » next coverage.py v7.15.1, created at 2026-10-13 20:52 +0000
1"""
2Retry logic with exponential backoff.
4Provides decorators for automatic retry of failing operations
5with configurable backoff strategies. Compatible with any
6Python framework (sync and async).
7"""
9import asyncio
10import functools
11import inspect
12import logging
13import math
14import secrets
15import time
16from collections.abc import Awaitable, Callable
17from dataclasses import dataclass
18from types import TracebackType
19from typing import NoReturn, ParamSpec, Protocol, TypeVar, cast, overload
21from taipanstack.core.result import Err
23P = ParamSpec("P")
24R = TypeVar("R")
27def _safe_str(e: Exception) -> str:
28 try:
29 return str(e)
30 except Exception:
31 try:
32 return repr(e)
33 except Exception:
34 return "<unprintable exception>"
37def _is_retryable_exception(
38 e: Exception, valid_on: tuple[type[Exception], ...] | type[Exception]
39) -> bool:
40 try:
41 return isinstance(e, valid_on)
42 except TypeError:
43 return False
46def _handle_retry_exception(
47 e: Exception,
48 attempt: int,
49 func_name: str,
50 valid_on: tuple[type[Exception], ...] | type[Exception],
51 config: "RetryConfig",
52) -> tuple[bool, float]:
53 """Handle a retry attempt exception.
55 Args:
56 e: The exception raised.
57 attempt: The current attempt number.
58 func_name: The name of the function.
59 valid_on: The exception types to retry on.
60 config: The retry configuration.
62 Returns:
63 True if the exception should be retried, False otherwise.
65 Raises:
66 Exception: If the exception is not matched and should not be retried.
68 """
69 if not _is_retryable_exception(e, valid_on):
70 raise e
72 max_attempts = (
73 int(config.max_attempts) if _is_valid_number(config.max_attempts) else 3
74 )
76 if attempt >= max_attempts:
77 _log_all_failed(func_name, e, config)
78 return False, 0.0
80 delay = calculate_delay(attempt, config)
81 _log_retry_attempt(func_name, attempt, e, delay, config)
82 return True, delay
85class RetryDecorator(Protocol):
86 """Protocol for the retry decorator."""
88 @overload
89 def __call__(self, func: Callable[P, R]) -> Callable[P, R]: ...
91 @overload
92 def __call__(
93 self,
94 func: Callable[P, Awaitable[R]],
95 ) -> Callable[P, Awaitable[R]]: ...
98logger = logging.getLogger("taipanstack.resilience.retry")
100try:
101 import structlog as _structlog
103 _structlog_logger = _structlog.get_logger("taipanstack.resilience.retry") # type: ignore[misc]
104 _HAS_STRUCTLOG = True
105except ImportError:
106 _structlog_logger = None
107 _HAS_STRUCTLOG = False
110def _validate_finite_or_default(
111 obj: object,
112 attr_name: str,
113 default_val: float | int,
114) -> None:
115 """Validate that an attribute is finite, falling back to a default."""
116 val = getattr(obj, attr_name) # type: ignore[misc]
117 if type(val) not in (int, float): # type: ignore[misc]
118 object.__setattr__(obj, attr_name, default_val)
119 return
120 if not math.isfinite(cast(float, val)) or cast(float, val) < 0:
121 raise ValueError(f"{attr_name} must be a finite non-negative number")
124@dataclass(frozen=True)
125class RetryConfig:
126 """Configuration for retry behavior.
128 Attributes:
129 max_attempts: Maximum number of retry attempts.
130 initial_delay: Initial delay between retries in seconds.
131 max_delay: Maximum delay between retries.
132 exponential_base: Base for exponential backoff (2 = double each time).
133 jitter: Whether to add random jitter to delays.
134 jitter_factor: Maximum jitter as fraction of delay (0.1 = 10%).
135 log_retries: Whether to emit standard log messages.
136 on_retry: Optional callback invoked on each retry.
138 """
140 max_attempts: int = 3
141 initial_delay: float = 1.0
142 max_delay: float = 60.0
143 exponential_base: float = 2.0
144 jitter: bool = True
145 jitter_factor: float = 0.1
146 log_retries: bool = True
147 on_retry: Callable[[int, int, Exception, float], None] | None = None
149 def __post_init__(self) -> None:
150 """Validate configuration parameters."""
151 _validate_finite_or_default(self, "max_attempts", 3)
152 _validate_finite_or_default(self, "initial_delay", 1.0)
153 _validate_finite_or_default(self, "max_delay", 60.0)
154 _validate_finite_or_default(self, "exponential_base", 2.0)
155 _validate_finite_or_default(self, "jitter_factor", 0.1)
158class RetryError(Exception):
159 """Raised when all retry attempts have failed."""
161 def __init__(
162 self,
163 message: str,
164 attempts: int,
165 last_exception: BaseException | None = None,
166 ) -> None:
167 """Initialize RetryError.
169 Args:
170 message: Description of the retry failure.
171 attempts: Number of attempts made.
172 last_exception: The last exception that was raised.
174 """
175 self.attempts = attempts
176 self.last_exception = last_exception
177 super().__init__(message)
180def _is_valid_number(val: object) -> bool:
181 """Check if the value is a valid finite number."""
182 return type(val) in (int, float) and math.isfinite(cast(float, val))
185def _compute_exponential_delay(attempt: int, config: RetryConfig) -> float:
186 try:
187 return config.initial_delay * (config.exponential_base ** (max(1, attempt) - 1))
188 except (OverflowError, TypeError):
189 return config.max_delay
192def _calculate_base_delay(attempt: int, config: RetryConfig) -> float:
193 """Calculate base delay with exponential backoff."""
194 delay = _compute_exponential_delay(attempt, config)
195 if not _is_valid_number(delay):
196 delay = config.max_delay
198 try:
199 if not _is_valid_number(delay):
200 return 0.0
201 return min(float(delay), float(config.max_delay))
202 except (TypeError, ValueError, Exception):
203 return 0.0
206def _compute_jitter_amount(delay: float, factor: float) -> float | None:
207 """Compute jitter amount safely."""
208 try:
209 amount = delay * factor
210 if isinstance(amount, (int, float)) and math.isfinite(amount):
211 return amount
212 except (TypeError, OverflowError, ValueError, Exception) as e:
213 logger.warning("Jitter mutation failed: %s", _safe_str(e))
214 return None
217def _add_jitter_to_delay(delay: float, jitter_amount: float) -> float:
218 try:
219 return delay + secrets.SystemRandom().uniform(-jitter_amount, jitter_amount)
220 except Exception as e:
221 logger.warning("Failed to add jitter to delay: %s", _safe_str(e))
222 return delay
225def _apply_jitter(delay: float, config: RetryConfig) -> float:
226 """Apply jitter to delay."""
227 if not config.jitter or not _is_valid_number(delay):
228 return delay
230 jitter_amount = _compute_jitter_amount(delay, config.jitter_factor)
231 if jitter_amount is None:
232 return delay
234 return _add_jitter_to_delay(delay, jitter_amount)
237def calculate_delay(
238 attempt: int,
239 config: RetryConfig,
240) -> float:
241 """Calculate delay before next retry.
243 Args:
244 attempt: Current attempt number (1-indexed).
245 config: Retry configuration.
247 Returns:
248 Delay in seconds before next retry.
250 """
251 delay = _calculate_base_delay(attempt, config)
252 delay = _apply_jitter(delay, config)
254 if not isinstance(delay, (int, float)) or not math.isfinite(delay) or delay < 0:
255 return 0.0
257 return delay
260def _log_retry_callback_failure(func_name: str, e: Exception) -> None:
261 """Log a failure during the retry callback execution."""
262 if _HAS_STRUCTLOG and _structlog_logger is not None: # type: ignore[misc]
263 _structlog_logger.error( # type: ignore[misc]
264 "retry_callback_failed",
265 function=func_name,
266 error=_safe_str(e),
267 )
268 else:
269 logger.error(
270 "Retry callback failed for %s: %s",
271 func_name,
272 _safe_str(e),
273 )
276def _log_retry_attempt_fallback(
277 func_name: str,
278 attempt: int,
279 exc: Exception,
280 delay: float,
281 config: RetryConfig,
282) -> None:
283 """Log the retry attempt if no callback is provided."""
284 if _HAS_STRUCTLOG and _structlog_logger is not None: # type: ignore[misc]
285 _structlog_logger.warning( # type: ignore[misc]
286 "retry_attempted",
287 function=func_name,
288 attempt=attempt,
289 max_attempts=config.max_attempts,
290 error=_safe_str(exc),
291 delay_seconds=round(delay, 3),
292 )
295def _invoke_retry_callback(
296 func_name: str,
297 attempt: int,
298 exc: Exception,
299 delay: float,
300 config: RetryConfig,
301) -> None:
302 """Invoke the retry callback if set, or emit structured log.
304 Args:
305 func_name: Name of the retried function.
306 attempt: Current attempt number.
307 exc: The exception that triggered the retry.
308 delay: Delay in seconds before the next attempt.
309 config: Retry configuration.
311 """
312 if config.on_retry is not None:
313 try:
314 config.on_retry(attempt, config.max_attempts, exc, delay)
315 except Exception as e:
316 _log_retry_callback_failure(func_name, e)
317 else:
318 _log_retry_attempt_fallback(func_name, attempt, exc, delay, config)
321def _log_retry_attempt(
322 func_name: str,
323 attempt: int,
324 exc: Exception,
325 delay: float,
326 config: RetryConfig,
327) -> None:
328 """Log a retry attempt via callback, structlog, or stdlib logger.
330 Args:
331 func_name: Name of the retried function.
332 attempt: Current attempt number.
333 exc: The exception that triggered the retry.
334 delay: Delay in seconds before the next attempt.
335 config: Retry configuration.
337 """
338 if config.log_retries:
339 logger.info(
340 "Attempt %d/%d failed for %s: %s. Retrying in %.2f seconds...",
341 attempt,
342 config.max_attempts,
343 func_name,
344 _safe_str(exc),
345 delay,
346 )
348 _invoke_retry_callback(func_name, attempt, exc, delay, config)
351def _log_all_failed(
352 func_name: str,
353 exc: Exception,
354 config: RetryConfig,
355) -> None:
356 """Log when all retry attempts have been exhausted.
358 Args:
359 func_name: Name of the retried function.
360 exc: The last exception raised.
361 config: Retry configuration.
363 """
364 if config.log_retries:
365 logger.warning(
366 "All %d attempts failed for %s: %s",
367 config.max_attempts,
368 func_name,
369 _safe_str(exc),
370 )
373def _raise_retry_error(
374 func_name: str,
375 max_attempts: int,
376 reraise: bool,
377 last_exception: BaseException | None,
378) -> NoReturn:
379 """Raise a RetryError after all attempts fail.
381 Args:
382 func_name: Name of the retried function.
383 max_attempts: Number of attempts made.
384 reraise: Whether to reraise the original exception.
385 last_exception: The last exception that was raised.
387 Raises:
388 RetryError: The wrapped or unwrapped exception.
390 """
391 if reraise and last_exception is not None:
392 raise RetryError(
393 f"All {max_attempts} attempts failed for {func_name}",
394 attempts=max_attempts,
395 last_exception=last_exception,
396 ) from last_exception
398 raise RetryError(
399 f"All {max_attempts} attempts failed for {func_name}",
400 attempts=max_attempts,
401 last_exception=last_exception,
402 )
405def _ensure_tuple(
406 on: tuple[type[Exception], ...] | type[Exception],
407) -> tuple[type[Exception], ...]:
408 """Ensure the exception parameter is a tuple of exception types."""
409 if isinstance(on, type) and issubclass(on, BaseException): # type: ignore[misc]
410 return (on,)
411 if not isinstance(on, tuple):
412 msg = ( # type: ignore[unreachable]
413 "'on' parameter must be an exception class or a tuple of exception classes"
414 )
415 raise TypeError(msg)
416 return on
419def _validate_retry_exceptions(
420 on: tuple[type[Exception], ...] | type[Exception],
421) -> tuple[type[Exception], ...]:
422 """Validate that the 'on' parameter contains valid exception types."""
423 on_tuple = _ensure_tuple(on)
425 for exc_type in on_tuple:
426 if not isinstance(exc_type, type) or not issubclass(exc_type, BaseException): # type: ignore[misc]
427 msg = ( # type: ignore[unreachable]
428 f"All elements in 'on' must be subclasses of BaseException, "
429 f"got {type(exc_type).__name__}"
430 )
431 raise TypeError(msg)
433 return on_tuple
436def _is_critical_sleep_exception(e: BaseException) -> bool:
437 return isinstance(e, (SystemExit, KeyboardInterrupt, GeneratorExit))
440async def _sleep_for_retry_async(delay: float) -> BaseException | None:
441 try:
442 await asyncio.sleep(min(delay, 3600.0))
443 except asyncio.CancelledError:
444 raise
445 except BaseException as sleep_e:
446 if _is_critical_sleep_exception(sleep_e):
447 raise
448 return sleep_e
449 return None
452def _sleep_for_retry_sync(delay: float) -> BaseException | None:
453 try:
454 time.sleep(min(delay, 3600.0))
455 except BaseException as sleep_e:
456 if _is_critical_sleep_exception(sleep_e):
457 raise
458 return sleep_e
459 return None
462async def _process_retry_attempt_async(
463 e: Exception,
464 attempt: int,
465 func_name: str,
466 valid_on: tuple[type[Exception], ...] | type[Exception],
467 config: RetryConfig,
468) -> BaseException | None:
469 should_retry, delay = _handle_retry_exception(
470 e, attempt, func_name, valid_on, config
471 )
472 if not should_retry:
473 return e
474 return await _sleep_for_retry_async(delay)
477def _process_retry_attempt_sync(
478 e: Exception,
479 attempt: int,
480 func_name: str,
481 valid_on: tuple[type[Exception], ...] | type[Exception],
482 config: RetryConfig,
483) -> BaseException | None:
484 should_retry, delay = _handle_retry_exception(
485 e, attempt, func_name, valid_on, config
486 )
487 if not should_retry:
488 return e
489 return _sleep_for_retry_sync(delay)
492def _check_result_for_retry(
493 result: object, valid_on: tuple[type[Exception], ...] | type[Exception]
494) -> None:
495 if isinstance(result, Err):
496 err_val = result.unwrap_err()
497 if isinstance(err_val, valid_on):
498 raise err_val
501def _get_max_attempts(config: RetryConfig) -> int:
502 return (
503 int(config.max_attempts)
504 if isinstance(config.max_attempts, (int, float))
505 and math.isfinite(config.max_attempts)
506 else 3
507 )
510def _handle_retry_failure_result(
511 last_result: R | None,
512 func_name: str,
513 config: RetryConfig,
514 reraise: bool,
515 last_exception: BaseException | None,
516) -> R:
517 if last_result is not None and isinstance(last_result, Err):
518 return cast(R, last_result)
519 _raise_retry_error(func_name, config.max_attempts, reraise, last_exception)
522def _handle_retry_exception_sync(
523 e: Exception,
524 attempt: int,
525 func_name: str,
526 valid_on: tuple[type[Exception], ...] | type[Exception],
527 config: RetryConfig,
528) -> tuple[BaseException, bool]:
529 sleep_err = _process_retry_attempt_sync(e, attempt, func_name, valid_on, config)
530 if sleep_err is not None:
531 if sleep_err is not e:
532 return sleep_err, True
533 return e, True
534 return e, False
537async def _handle_retry_exception_async(
538 e: Exception,
539 attempt: int,
540 func_name: str,
541 valid_on: tuple[type[Exception], ...] | type[Exception],
542 config: RetryConfig,
543) -> tuple[BaseException, bool]:
544 sleep_err = await _process_retry_attempt_async(
545 e, attempt, func_name, valid_on, config
546 )
547 if sleep_err is not None:
548 if sleep_err is not e:
549 return sleep_err, True
550 return e, True
551 return e, False
554async def _run_retry_async(
555 func_coro: Callable[P, Awaitable[R]],
556 func_name_coro: str,
557 config: RetryConfig,
558 valid_on: tuple[type[Exception], ...] | type[Exception],
559 reraise: bool,
560 *args: P.args,
561 **kwargs: P.kwargs,
562) -> R:
563 last_exception: BaseException | None = None
564 last_result: R | None = None
565 max_attempts = _get_max_attempts(config)
567 for attempt in range(1, max_attempts + 1):
568 last_result = None
569 try:
570 last_result = await func_coro(*args, **kwargs)
571 _check_result_for_retry(last_result, valid_on)
572 return last_result
573 except Exception as e:
574 last_exception, should_break = await _handle_retry_exception_async(
575 e, attempt, func_name_coro, valid_on, config
576 )
577 if should_break:
578 break
580 return _handle_retry_failure_result(
581 last_result, func_name_coro, config, reraise, last_exception
582 )
585def _execute_async_wrapper(
586 func_coro: Callable[P, Awaitable[R]],
587 func_name_coro: str,
588 config: RetryConfig,
589 valid_on: tuple[type[Exception], ...] | type[Exception],
590 reraise: bool,
591) -> Callable[P, Awaitable[R]]:
592 @functools.wraps(func_coro)
593 async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
594 return await _run_retry_async(
595 func_coro, func_name_coro, config, valid_on, reraise, *args, **kwargs
596 )
598 return async_wrapper # type: ignore[misc]
601def _run_retry_sync(
602 func_sync: Callable[P, R],
603 func_name_sync: str,
604 config: RetryConfig,
605 valid_on: tuple[type[Exception], ...] | type[Exception],
606 reraise: bool,
607 *args: P.args,
608 **kwargs: P.kwargs,
609) -> R:
610 last_exception: BaseException | None = None
611 last_result: R | None = None
612 max_attempts = _get_max_attempts(config)
614 for attempt in range(1, max_attempts + 1):
615 last_result = None
616 try:
617 last_result = func_sync(*args, **kwargs)
618 _check_result_for_retry(last_result, valid_on)
619 return last_result
620 except Exception as e:
621 last_exception, should_break = _handle_retry_exception_sync(
622 e, attempt, func_name_sync, valid_on, config
623 )
624 if should_break:
625 break
627 return _handle_retry_failure_result(
628 last_result, func_name_sync, config, reraise, last_exception
629 )
632def _execute_sync_wrapper(
633 func_sync: Callable[P, R],
634 func_name_sync: str,
635 config: RetryConfig,
636 valid_on: tuple[type[Exception], ...] | type[Exception],
637 reraise: bool,
638) -> Callable[P, R]:
639 @functools.wraps(func_sync)
640 def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
641 return _run_retry_sync(
642 func_sync, func_name_sync, config, valid_on, reraise, *args, **kwargs
643 )
645 return wrapper
648def retry(
649 *,
650 max_attempts: int = 3,
651 initial_delay: float = 1.0,
652 max_delay: float = 60.0,
653 exponential_base: float = 2.0,
654 jitter: bool = True,
655 on: tuple[type[Exception], ...] | type[Exception] = (Exception,),
656 reraise: bool = True,
657 log_retries: bool = True,
658 on_retry: Callable[[int, int, Exception, float], None] | None = None,
659) -> RetryDecorator:
660 """Retry a sync or async function with exponential backoff.
662 Automatically retries the decorated function when specified
663 exceptions are raised, with configurable backoff strategy.
664 Detects coroutine functions and preserves their async nature.
666 Args:
667 max_attempts: Maximum number of retry attempts.
668 initial_delay: Initial delay between retries in seconds.
669 max_delay: Maximum delay between retries.
670 exponential_base: Base for exponential backoff.
671 jitter: Whether to add random jitter to delays.
672 on: Exception types to retry on.
673 reraise: Whether to reraise the last exception on failure.
674 log_retries: Whether to log retry attempts.
675 on_retry: Optional callback invoked on each retry with
676 (attempt, max_attempts, exception, delay). Useful for
677 custom monitoring or metrics collection.
679 Returns:
680 Decorated function with retry logic.
682 Example:
683 >>> @retry(max_attempts=3, on=(ConnectionError, TimeoutError))
684 ... def fetch_data(url: str) -> dict:
685 ... return requests.get(url, timeout=10).json()
687 >>> @retry(max_attempts=3, on_retry=lambda a, m, e, d: print(f"Retry {a}/{m}"))
688 ... def fragile_operation() -> str:
689 ... return do_something()
691 """
692 # Validate 'on' parameter at definition time (Fail-Fast)
693 valid_on = _validate_retry_exceptions(on)
695 config = RetryConfig(
696 max_attempts=max_attempts,
697 initial_delay=initial_delay,
698 max_delay=max_delay,
699 exponential_base=exponential_base,
700 jitter=jitter,
701 log_retries=log_retries,
702 on_retry=on_retry,
703 )
705 def decorator(
706 func: Callable[P, R] | Callable[P, Awaitable[R]],
707 ) -> Callable[P, R] | Callable[P, Awaitable[R]]:
708 if inspect.iscoroutinefunction(func):
709 func_coro = cast(Callable[P, Awaitable[R]], func)
710 func_name_coro = cast(str, getattr(func_coro, "__name__", "unknown"))
711 return _execute_async_wrapper(
712 func_coro, func_name_coro, config, valid_on, reraise
713 )
715 func_sync = cast(Callable[P, R], func)
716 func_name_sync = cast(str, getattr(func_sync, "__name__", "unknown"))
717 return _execute_sync_wrapper(
718 func_sync, func_name_sync, config, valid_on, reraise
719 )
721 return cast(RetryDecorator, decorator)
724def retry_on_exception(
725 exception_types: tuple[type[Exception], ...],
726 max_attempts: int = 3,
727) -> RetryDecorator:
728 """Retry on specific exceptions.
730 A simpler alternative to the full retry decorator when you
731 just need basic retry functionality.
733 Args:
734 exception_types: Exception types to retry on.
735 max_attempts: Maximum number of attempts.
737 Returns:
738 Decorated function with retry logic.
740 Example:
741 >>> @retry_on_exception((ValueError,), max_attempts=2)
742 ... def parse_data(data: str) -> dict:
743 ... return json.loads(data)
745 """
746 return retry(
747 max_attempts=max_attempts,
748 on=exception_types,
749 jitter=False,
750 log_retries=False,
751 )
754class Retrier:
755 """Context manager for retry logic.
757 Provides a context manager interface for retry logic when
758 decorators are not suitable.
760 Example:
761 >>> retrier = Retrier(max_attempts=3, on=(ConnectionError,))
762 >>> with retrier:
763 ... result = some_operation()
765 """
767 def __init__(
768 self,
769 *,
770 max_attempts: int = 3,
771 initial_delay: float = 1.0,
772 max_delay: float = 60.0,
773 on: tuple[type[Exception], ...] = (Exception,),
774 ) -> None:
775 """Initialize Retrier.
777 Args:
778 max_attempts: Maximum retry attempts.
779 initial_delay: Initial delay between retries.
780 max_delay: Maximum delay between retries.
781 on: Exception types to retry on.
783 """
784 self.config = RetryConfig(
785 max_attempts=max_attempts,
786 initial_delay=initial_delay,
787 max_delay=max_delay,
788 )
789 self.exception_types = on
790 self.attempt = 0
791 self.last_exception: BaseException | None = None
793 def __enter__(self) -> "Retrier":
794 """Enter the retry context."""
795 return self
797 def _increment_attempt(self) -> bool:
798 """Increment attempt counter safely."""
799 if (
800 not isinstance(self.attempt, (int, float))
801 or not math.isfinite(self.attempt)
802 or self.attempt < 0
803 ):
804 return False
806 self.attempt += 1
807 return True
809 def _is_matching_exception(self, exc_type: type[BaseException] | None) -> bool:
810 """Check if the exception matches the types to retry on."""
811 if exc_type is None:
812 return False
813 try:
814 return issubclass(exc_type, self.exception_types)
815 except TypeError:
816 return False
818 def _should_retry(self, exc_type: type[BaseException] | None) -> bool:
819 """Determine if an exception should trigger a retry."""
820 if not self._is_matching_exception(exc_type):
821 return False
823 if not self._increment_attempt():
824 return False
826 return self.attempt < _get_max_attempts(self.config)
828 def _handle_exception(self, exc_val: BaseException | None) -> None:
829 """Store the exception safely."""
830 if exc_val is not None:
831 self.last_exception = exc_val if isinstance(exc_val, Exception) else None
833 def _sleep_for_retry(self, delay: float) -> bool:
834 """Sleep for the retry delay.
836 Returns True if sleep was successful, False if it was interrupted
837 by a non-critical exception.
838 """
839 try:
840 time.sleep(min(delay, 3600.0))
841 except BaseException as sleep_e:
842 if isinstance(sleep_e, (SystemExit, KeyboardInterrupt, GeneratorExit)):
843 raise
844 # If sleep fails, we cannot retry properly, let original exception propagate
845 return False
846 return True
848 def __exit__(
849 self,
850 exc_type: type[BaseException] | None,
851 exc_val: BaseException | None,
852 _exc_tb: TracebackType | None,
853 ) -> bool:
854 """Exit the retry context.
856 Returns True to suppress the exception if we should retry,
857 False to let it propagate.
858 """
859 # Safe cast: check inside _should_retry ensures we handle it right
860 self._handle_exception(exc_val)
862 if not self._should_retry(exc_type):
863 return False
865 # Calculate delay and wait
866 delay = calculate_delay(self.attempt, self.config)
867 return self._sleep_for_retry(delay)