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

1""" 

2Retry logic with exponential backoff. 

3 

4Provides decorators for automatic retry of failing operations 

5with configurable backoff strategies. Compatible with any 

6Python framework (sync and async). 

7""" 

8 

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 

20 

21from taipanstack.core.result import Err 

22 

23P = ParamSpec("P") 

24R = TypeVar("R") 

25 

26 

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>" 

35 

36 

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 

44 

45 

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. 

54 

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. 

61 

62 Returns: 

63 True if the exception should be retried, False otherwise. 

64 

65 Raises: 

66 Exception: If the exception is not matched and should not be retried. 

67 

68 """ 

69 if not _is_retryable_exception(e, valid_on): 

70 raise e 

71 

72 max_attempts = ( 

73 int(config.max_attempts) if _is_valid_number(config.max_attempts) else 3 

74 ) 

75 

76 if attempt >= max_attempts: 

77 _log_all_failed(func_name, e, config) 

78 return False, 0.0 

79 

80 delay = calculate_delay(attempt, config) 

81 _log_retry_attempt(func_name, attempt, e, delay, config) 

82 return True, delay 

83 

84 

85class RetryDecorator(Protocol): 

86 """Protocol for the retry decorator.""" 

87 

88 @overload 

89 def __call__(self, func: Callable[P, R]) -> Callable[P, R]: ... 

90 

91 @overload 

92 def __call__( 

93 self, 

94 func: Callable[P, Awaitable[R]], 

95 ) -> Callable[P, Awaitable[R]]: ... 

96 

97 

98logger = logging.getLogger("taipanstack.resilience.retry") 

99 

100try: 

101 import structlog as _structlog 

102 

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 

108 

109 

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

122 

123 

124@dataclass(frozen=True) 

125class RetryConfig: 

126 """Configuration for retry behavior. 

127 

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. 

137 

138 """ 

139 

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 

148 

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) 

156 

157 

158class RetryError(Exception): 

159 """Raised when all retry attempts have failed.""" 

160 

161 def __init__( 

162 self, 

163 message: str, 

164 attempts: int, 

165 last_exception: BaseException | None = None, 

166 ) -> None: 

167 """Initialize RetryError. 

168 

169 Args: 

170 message: Description of the retry failure. 

171 attempts: Number of attempts made. 

172 last_exception: The last exception that was raised. 

173 

174 """ 

175 self.attempts = attempts 

176 self.last_exception = last_exception 

177 super().__init__(message) 

178 

179 

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

183 

184 

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 

190 

191 

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 

197 

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 

204 

205 

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 

215 

216 

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 

223 

224 

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 

229 

230 jitter_amount = _compute_jitter_amount(delay, config.jitter_factor) 

231 if jitter_amount is None: 

232 return delay 

233 

234 return _add_jitter_to_delay(delay, jitter_amount) 

235 

236 

237def calculate_delay( 

238 attempt: int, 

239 config: RetryConfig, 

240) -> float: 

241 """Calculate delay before next retry. 

242 

243 Args: 

244 attempt: Current attempt number (1-indexed). 

245 config: Retry configuration. 

246 

247 Returns: 

248 Delay in seconds before next retry. 

249 

250 """ 

251 delay = _calculate_base_delay(attempt, config) 

252 delay = _apply_jitter(delay, config) 

253 

254 if not isinstance(delay, (int, float)) or not math.isfinite(delay) or delay < 0: 

255 return 0.0 

256 

257 return delay 

258 

259 

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 ) 

274 

275 

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 ) 

293 

294 

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. 

303 

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. 

310 

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) 

319 

320 

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. 

329 

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. 

336 

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 ) 

347 

348 _invoke_retry_callback(func_name, attempt, exc, delay, config) 

349 

350 

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. 

357 

358 Args: 

359 func_name: Name of the retried function. 

360 exc: The last exception raised. 

361 config: Retry configuration. 

362 

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 ) 

371 

372 

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. 

380 

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. 

386 

387 Raises: 

388 RetryError: The wrapped or unwrapped exception. 

389 

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 

397 

398 raise RetryError( 

399 f"All {max_attempts} attempts failed for {func_name}", 

400 attempts=max_attempts, 

401 last_exception=last_exception, 

402 ) 

403 

404 

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 

417 

418 

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) 

424 

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) 

432 

433 return on_tuple 

434 

435 

436def _is_critical_sleep_exception(e: BaseException) -> bool: 

437 return isinstance(e, (SystemExit, KeyboardInterrupt, GeneratorExit)) 

438 

439 

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 

450 

451 

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 

460 

461 

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) 

475 

476 

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) 

490 

491 

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 

499 

500 

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 ) 

508 

509 

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) 

520 

521 

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 

535 

536 

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 

552 

553 

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) 

566 

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 

579 

580 return _handle_retry_failure_result( 

581 last_result, func_name_coro, config, reraise, last_exception 

582 ) 

583 

584 

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 ) 

597 

598 return async_wrapper # type: ignore[misc] 

599 

600 

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) 

613 

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 

626 

627 return _handle_retry_failure_result( 

628 last_result, func_name_sync, config, reraise, last_exception 

629 ) 

630 

631 

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 ) 

644 

645 return wrapper 

646 

647 

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. 

661 

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. 

665 

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. 

678 

679 Returns: 

680 Decorated function with retry logic. 

681 

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

686 

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

690 

691 """ 

692 # Validate 'on' parameter at definition time (Fail-Fast) 

693 valid_on = _validate_retry_exceptions(on) 

694 

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 ) 

704 

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 ) 

714 

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 ) 

720 

721 return cast(RetryDecorator, decorator) 

722 

723 

724def retry_on_exception( 

725 exception_types: tuple[type[Exception], ...], 

726 max_attempts: int = 3, 

727) -> RetryDecorator: 

728 """Retry on specific exceptions. 

729 

730 A simpler alternative to the full retry decorator when you 

731 just need basic retry functionality. 

732 

733 Args: 

734 exception_types: Exception types to retry on. 

735 max_attempts: Maximum number of attempts. 

736 

737 Returns: 

738 Decorated function with retry logic. 

739 

740 Example: 

741 >>> @retry_on_exception((ValueError,), max_attempts=2) 

742 ... def parse_data(data: str) -> dict: 

743 ... return json.loads(data) 

744 

745 """ 

746 return retry( 

747 max_attempts=max_attempts, 

748 on=exception_types, 

749 jitter=False, 

750 log_retries=False, 

751 ) 

752 

753 

754class Retrier: 

755 """Context manager for retry logic. 

756 

757 Provides a context manager interface for retry logic when 

758 decorators are not suitable. 

759 

760 Example: 

761 >>> retrier = Retrier(max_attempts=3, on=(ConnectionError,)) 

762 >>> with retrier: 

763 ... result = some_operation() 

764 

765 """ 

766 

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. 

776 

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. 

782 

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 

792 

793 def __enter__(self) -> "Retrier": 

794 """Enter the retry context.""" 

795 return self 

796 

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 

805 

806 self.attempt += 1 

807 return True 

808 

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 

817 

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 

822 

823 if not self._increment_attempt(): 

824 return False 

825 

826 return self.attempt < _get_max_attempts(self.config) 

827 

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 

832 

833 def _sleep_for_retry(self, delay: float) -> bool: 

834 """Sleep for the retry delay. 

835 

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 

847 

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. 

855 

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) 

861 

862 if not self._should_retry(exc_type): 

863 return False 

864 

865 # Calculate delay and wait 

866 delay = calculate_delay(self.attempt, self.config) 

867 return self._sleep_for_retry(delay)