retry.py 3.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
  1. import functools
  2. import logging
  3. import random
  4. import time
  5. from collections.abc import Sequence
  6. from typing import Callable, Optional, TypeVar
  7. try:
  8. from typing import ParamSpec
  9. except ImportError:
  10. from typing_extensions import ParamSpec
  11. logger = logging.getLogger(__name__)
  12. R = TypeVar("R")
  13. P = ParamSpec("P")
  14. def call_with_retry(
  15. f: Callable[P, R],
  16. description: str,
  17. match: Optional[Sequence[str]] = None,
  18. max_attempts: int = 10,
  19. max_backoff_s: int = 32,
  20. *args: P.args,
  21. **kwargs: P.kwargs,
  22. ) -> R:
  23. """Retry a function with exponential backoff.
  24. Args:
  25. f: The function to retry.
  26. description: An imperative description of the function being retried. For
  27. example, "open the file".
  28. match: A sequence of strings to match in the exception message.
  29. If ``None``, any error is retried.
  30. max_attempts: The maximum number of attempts to retry.
  31. max_backoff_s: The maximum number of seconds to backoff.
  32. *args: Arguments to pass to the function.
  33. **kwargs: Keyword arguments to pass to the function.
  34. Returns:
  35. The result of the function.
  36. """
  37. # TODO: consider inverse match and matching exception type
  38. assert max_attempts >= 1, f"`max_attempts` must be positive. Got {max_attempts}."
  39. for i in range(max_attempts):
  40. try:
  41. return f(*args, **kwargs)
  42. except Exception as e:
  43. exception_str = str(e)
  44. is_retryable = match is None or any(
  45. pattern in exception_str for pattern in match
  46. )
  47. if is_retryable and i + 1 < max_attempts:
  48. # Retry with binary exponential backoff with 20% random jitter.
  49. backoff = min(2**i, max_backoff_s) * (random.uniform(0.8, 1.2))
  50. logger.debug(
  51. f"Retrying {i+1} attempts to {description} after {backoff} seconds."
  52. )
  53. time.sleep(backoff)
  54. else:
  55. if is_retryable:
  56. logger.debug(
  57. f"Failed to {description} after {max_attempts} attempts. Raising."
  58. )
  59. else:
  60. logger.debug(
  61. f"Did not find a match for {exception_str}. Raising after {i+1} attempts."
  62. )
  63. raise e from None
  64. def retry(
  65. description: str,
  66. match: Optional[Sequence[str]] = None,
  67. max_attempts: int = 10,
  68. max_backoff_s: int = 32,
  69. ) -> Callable[[Callable[P, R]], Callable[P, R]]:
  70. """Decorator-based version of call_with_retry.
  71. Args:
  72. description: An imperative description of the function being retried. For
  73. example, "open the file".
  74. match: A sequence of strings to match in the exception message.
  75. If ``None``, any error is retried.
  76. max_attempts: The maximum number of attempts to retry.
  77. max_backoff_s: The maximum number of seconds to backoff.
  78. Returns:
  79. A Callable that can be applied in a normal decorator fashion.
  80. """
  81. def decorator(func: Callable[P, R]) -> Callable[P, R]:
  82. @functools.wraps(func)
  83. def inner(*args: P.args, **kwargs: P.kwargs) -> R:
  84. return call_with_retry(
  85. func, description, match, max_attempts, max_backoff_s, *args, **kwargs
  86. )
  87. return inner
  88. return decorator