From 667333935521d22327edd35ffe9d4811c7623156 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20Ram=C3=ADrez=20Mondrag=C3=B3n?= Date: Wed, 5 Aug 2026 19:59:37 -0600 Subject: [PATCH] feat: Add a retry context manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Edgar Ramírez Mondragón --- backoff/__init__.py | 4 +- backoff/_async.py | 78 ++++++- backoff/_common.py | 105 +++++++-- backoff/_decorator.py | 159 ++++++++++++- backoff/_sync.py | 69 +++++- backoff/_typing.py | 21 ++ docs/api/reference.md | 12 + docs/examples.md | 24 ++ docs/index.md | 40 +--- docs/user-guide/context-manager.md | 19 ++ tests/test_retry_context.py | 348 +++++++++++++++++++++++++++++ zensical.toml | 1 + 12 files changed, 821 insertions(+), 59 deletions(-) create mode 100644 docs/user-guide/context-manager.md create mode 100644 tests/test_retry_context.py diff --git a/backoff/__init__.py b/backoff/__init__.py index 524e011..ac1e37e 100644 --- a/backoff/__init__.py +++ b/backoff/__init__.py @@ -12,11 +12,12 @@ https://github.com/python-backoff/backoff """ -from backoff._decorator import on_exception, on_predicate +from backoff._decorator import aretry_context, on_exception, on_predicate, retry_context from backoff._jitter import full_jitter, random_jitter from backoff._wait_gen import constant, decay, expo, fibo, runtime __all__ = [ + "aretry_context", "constant", "decay", "expo", @@ -25,6 +26,7 @@ "on_exception", "on_predicate", "random_jitter", + "retry_context", "runtime", ] diff --git a/backoff/_async.py b/backoff/_async.py index 4d35a0a..942cde3 100644 --- a/backoff/_async.py +++ b/backoff/_async.py @@ -5,16 +5,17 @@ import inspect from typing import TYPE_CHECKING, Any, Callable, TypeVar -from backoff._common import _RetryState +from backoff._common import _Attempt, _RetryState if TYPE_CHECKING: import sys - from collections.abc import Coroutine, Iterable + from collections.abc import AsyncGenerator, Coroutine, Iterable from backoff._typing import ( Details, _BaseDetails, _CallDetails, + _ContextHandler, _Handler, _Jitterer, _MaybeCallable, @@ -237,3 +238,76 @@ async def retry( return ret return retry # type: ignore[return-value] # ty:ignore[invalid-return-type] + + +async def _dispatch_handlers( + handlers: Iterable[_ContextHandler], **details: Any +) -> None: + for hdlr in _ensure_coroutines(handlers): + await hdlr(details) + + +async def aretry_context( + exception: _MaybeSequence[type[Exception]], + wait_gen: _WaitGenerator, + *, + max_tries: _MaybeCallable[int] | None, + max_time: _MaybeCallable[float] | None, + jitter: _Jitterer | None, + giveup: _Predicate[BaseException], + on_success: Iterable[_ContextHandler], + on_backoff: Iterable[_ContextHandler], + on_giveup: Iterable[_ContextHandler], + raise_on_giveup: bool, + wait_gen_kwargs: dict[str, Any], +) -> AsyncGenerator[_Attempt, None]: + giveup = _ensure_coroutine(giveup) + + state = _RetryState( + wait_gen, + wait_gen_kwargs, + max_tries=max_tries, + max_time=max_time, + ) + while True: + state.start_attempt() + attempt = _Attempt(exception) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] + yield attempt + elapsed = state.record_elapsed() + + exc = attempt.exception + if exc is None: + await _dispatch_handlers(on_success, tries=state.tries, elapsed=elapsed) + return + + if await giveup(exc) or state.exhausted(): + await _dispatch_handlers( + on_giveup, + tries=state.tries, + elapsed=elapsed, + exception=exc, + ) + if raise_on_giveup: + raise exc + return + + try: + seconds = state.next_wait(exc, jitter) + except StopIteration: + await _dispatch_handlers( + on_giveup, + tries=state.tries, + elapsed=elapsed, + exception=exc, + ) + raise exc from None + + await _dispatch_handlers( + on_backoff, + tries=state.tries, + elapsed=elapsed, + wait=seconds, + exception=exc, + ) + + await asyncio.sleep(seconds) diff --git a/backoff/_common.py b/backoff/_common.py index 6c1f387..8d172d0 100644 --- a/backoff/_common.py +++ b/backoff/_common.py @@ -6,28 +6,24 @@ import time import traceback import warnings -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any, Callable, TypeVar if TYPE_CHECKING: from collections.abc import Generator, Iterable - from typing import Protocol from backoff._typing import ( + ContextDetails, Details, - _Handler, + _ContextHandler, _Jitterer, _MaybeCallable, _WaitGenerator, ) - class _DefaultHandler(Protocol): - def __call__( - self, - details: Details, - *, - logger: logging.Logger | logging.LoggerAdapter, - log_level: int, - ) -> None: ... + if sys.version_info >= (3, 11): + from typing import Self + else: + from typing_extensions import Self # Use module-specific logger with a default null handler. @@ -36,6 +32,7 @@ def __call__( _logger.setLevel(logging.INFO) T = TypeVar("T") +_HandlerT = TypeVar("_HandlerT") # Evaluate arg that can be either a fixed value or a callable. @@ -135,6 +132,52 @@ def next_wait(self, send_value: Any, jitter: _Jitterer | None) -> float: return _next_wait(self.wait, send_value, jitter, self.elapsed, self.max_time) +class _Attempt: + """A single attempt yielded by `retry_context`/`aretry_context`. + + Used as `with attempt: ...`. Exceptions matching `exception_types` are + caught here so the driving generator (not this object) decides whether + to retry, sleep, or let the exception propagate; anything else, or no + exception at all, is left for the `with` block's normal exit behavior. + """ + + __slots__ = ( + "exception", + "exception_types", + ) + + def __init__( + self, + exception_types: type[Exception] | tuple[type[Exception], ...], + ) -> None: + self.exception_types = exception_types + self.exception: BaseException | None = None + + def __enter__(self) -> Self: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: object, + ) -> bool: + if exc_type is None: + # self.outcome = _Success() + return False + + if not issubclass(exc_type, self.exception_types): + return False # not ours; propagate immediately + + self.exception = exc + return True # suppress for now; the driving generator decides next + + +def _dispatch_handlers(handlers: Iterable[_ContextHandler], **details: Any) -> None: + for hdlr in handlers: + hdlr(details) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] + + def _prepare_logger( logger: str | logging.Logger | logging.LoggerAdapter | None, ) -> logging.Logger | logging.LoggerAdapter | None: @@ -146,13 +189,13 @@ def _prepare_logger( # Configure handler list with user specified handler and optionally # with a default handler bound to the specified logger. def _config_handlers( - user_handlers: _Handler | Iterable[_Handler] | None, + user_handlers: _HandlerT | Iterable[_HandlerT] | None, *, - default_handler: _DefaultHandler | None = None, + default_handler: Callable[..., None] | None = None, logger: logging.Logger | logging.LoggerAdapter | None = None, log_level: int | None = None, -) -> list[_Handler]: - handlers: list[_Handler] = [] +) -> list[_HandlerT]: + handlers: list[_HandlerT] = [] if logger is not None: assert log_level is not None, "Log level is not specified" assert default_handler is not None, "Default handler is not specified" @@ -162,7 +205,7 @@ def _config_handlers( logger=logger, log_level=log_level, ) - handlers.append(log_handler) + handlers.append(log_handler) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] if user_handlers is None: return handlers @@ -216,3 +259,33 @@ def _log_giveup( log_args.append(details["value"]) logger.log(log_level, msg, *log_args) + + +# Default backoff handler for retry_context/aretry_context (no wrapped +# callable, so no name to log; the exception is read from `details` +# directly since it's no longer the active exception by this point). +def _log_backoff_context( + details: ContextDetails, + logger: logging.Logger | logging.LoggerAdapter, + log_level: int, +) -> None: + logger.log( + log_level, + "Backing off retry_context(...) for %.1fs (%s)", + details["wait"], + details.get("exception"), + ) + + +# Default giveup handler for retry_context/aretry_context. +def _log_giveup_context( + details: ContextDetails, + logger: logging.Logger | logging.LoggerAdapter, + log_level: int, +) -> None: + logger.log( + log_level, + "Giving up retry_context(...) after %d tries (%s)", + details["tries"], + details.get("exception"), + ) diff --git a/backoff/_decorator.py b/backoff/_decorator.py index e36f4a3..654b596 100644 --- a/backoff/_decorator.py +++ b/backoff/_decorator.py @@ -9,16 +9,21 @@ from backoff._common import ( _config_handlers, _log_backoff, + _log_backoff_context, _log_giveup, + _log_giveup_context, _prepare_logger, ) from backoff._jitter import full_jitter +from backoff._wait_gen import expo if TYPE_CHECKING: import sys - from collections.abc import Iterable + from collections.abc import AsyncGenerator, Generator, Iterable + from backoff._common import _Attempt from backoff._typing import ( + _ContextHandler, _Handler, _Jitterer, _MaybeCallable, @@ -239,3 +244,155 @@ def decorate(target: Callable[P, T]) -> Callable[P, T]: # Return a function which decorates a target with a retry loop. return decorate + + +def retry_context( + exception: _MaybeSequence[type[Exception]] = Exception, + wait_gen: _WaitGenerator = expo, + *, + max_tries: _MaybeCallable[int] | None = None, + max_time: _MaybeCallable[float] | None = None, + jitter: _Jitterer | None = full_jitter, + giveup: _Predicate[BaseException] = lambda e: False, + on_success: _ContextHandler | Iterable[_ContextHandler] | None = None, + on_backoff: _ContextHandler | Iterable[_ContextHandler] | None = None, + on_giveup: _ContextHandler | Iterable[_ContextHandler] | None = None, + raise_on_giveup: bool = True, + logger: _MaybeLogger = "backoff", + backoff_log_level: int = logging.INFO, + giveup_log_level: int = logging.ERROR, + **wait_gen_kwargs: Any, +) -> Generator[_Attempt, None, None]: + """Returns a generator of retry attempts, for direct use with a `for` loop. + + Unlike `on_exception`, this doesn't wrap a whole function; it lets a + caller retry an arbitrary block of code: + + for attempt in backoff.retry_context(ValueError, backoff.expo): + with attempt: + do_something() + + Each `attempt` is a context manager: exceptions matching `exception` + are caught, and the loop either sleeps and retries or lets the + exception (or a different one) propagate once retries are exhausted. + Succeeding (no exception raised in the `with` block) ends the loop. + + Args: + exception: An exception type (or tuple of types) which triggers + backoff. + wait_gen: A generator yielding successive wait times in seconds. + max_tries: The maximum number of attempts to make before giving + up. Once exhausted, the exception will be allowed to escape. + The default value of None means there is no limit to the + number of tries. If a callable is passed, it will be + evaluated at runtime and its return value used. + max_time: The maximum total amount of time to try for before + giving up. Once expired, the exception will be allowed to + escape. If a callable is passed, it will be evaluated at + runtime and its return value used. + jitter: A function of the value yielded by wait_gen returning + the actual time to wait. Jittered by default using + full_jitter; disable with jitter=None. + giveup: Function accepting an exception instance and returning + whether or not to give up. Optional. The default is to + always continue. + on_success: Callable (or iterable of callables) with a unary + signature called on success. The parameter is a dict with + `tries` and `elapsed`. + on_backoff: Callable (or iterable of callables) called on + backoff. The parameter dict additionally has `wait` and + `exception`. + on_giveup: Callable (or iterable of callables) called when + giving up. The parameter dict additionally has `exception`. + raise_on_giveup: Boolean indicating whether the registered + exception should be raised on giveup. Defaults to `True`. + logger: Name or Logger object to log to. Defaults to 'backoff'. + backoff_log_level: log level for the backoff event. Defaults to "INFO" + giveup_log_level: log level for the give up event. Defaults to "ERROR" + **wait_gen_kwargs: Any additional keyword args specified will be + passed to wait_gen when it is initialized. + """ + logger = _prepare_logger(logger) + on_success = _config_handlers(on_success) + on_backoff = _config_handlers( + on_backoff, + default_handler=_log_backoff_context, + logger=logger, + log_level=backoff_log_level, + ) + on_giveup = _config_handlers( + on_giveup, + default_handler=_log_giveup_context, + logger=logger, + log_level=giveup_log_level, + ) + + return _sync.retry_context( + exception, + wait_gen, + max_tries=max_tries, + max_time=max_time, + jitter=jitter, + giveup=giveup, + on_success=on_success, + on_backoff=on_backoff, + on_giveup=on_giveup, + raise_on_giveup=raise_on_giveup, + wait_gen_kwargs=wait_gen_kwargs, + ) + + +def aretry_context( + exception: _MaybeSequence[type[Exception]] = Exception, + wait_gen: _WaitGenerator = expo, + *, + max_tries: _MaybeCallable[int] | None = None, + max_time: _MaybeCallable[float] | None = None, + jitter: _Jitterer | None = full_jitter, + giveup: _Predicate[BaseException] = lambda e: False, + on_success: _ContextHandler | Iterable[_ContextHandler] | None = None, + on_backoff: _ContextHandler | Iterable[_ContextHandler] | None = None, + on_giveup: _ContextHandler | Iterable[_ContextHandler] | None = None, + raise_on_giveup: bool = True, + logger: _MaybeLogger = "backoff", + backoff_log_level: int = logging.INFO, + giveup_log_level: int = logging.ERROR, + **wait_gen_kwargs: Any, +) -> AsyncGenerator[_Attempt, None]: + """Async counterpart to `retry_context`, for use with `async for`. + + async for attempt in backoff.aretry_context(ValueError, backoff.expo): + with attempt: + await do_something() + + `giveup` and the handlers may be sync or async callables. See + `retry_context` for the full argument reference. + """ + logger = _prepare_logger(logger) + on_success = _config_handlers(on_success) + on_backoff = _config_handlers( + on_backoff, + default_handler=_log_backoff_context, + logger=logger, + log_level=backoff_log_level, + ) + on_giveup = _config_handlers( + on_giveup, + default_handler=_log_giveup_context, + logger=logger, + log_level=giveup_log_level, + ) + + return _async.aretry_context( + exception, + wait_gen, + max_tries=max_tries, + max_time=max_time, + jitter=jitter, + giveup=giveup, + on_success=on_success, + on_backoff=on_backoff, + on_giveup=on_giveup, + raise_on_giveup=raise_on_giveup, + wait_gen_kwargs=wait_gen_kwargs, + ) diff --git a/backoff/_sync.py b/backoff/_sync.py index 86fd63e..89d93be 100644 --- a/backoff/_sync.py +++ b/backoff/_sync.py @@ -4,16 +4,17 @@ import time from typing import TYPE_CHECKING, Any, Callable, TypeVar -from backoff._common import _RetryState +from backoff._common import _Attempt, _dispatch_handlers, _RetryState if TYPE_CHECKING: import sys - from collections.abc import Iterable + from collections.abc import Generator, Iterable from backoff._typing import ( Details, _BaseDetails, _CallDetails, + _ContextHandler, _Handler, _Jitterer, _MaybeCallable, @@ -173,3 +174,67 @@ def retry(*args: P.args, **kwargs: P.kwargs) -> T: # type: ignore[return] # ty return ret return retry + + +def retry_context( + exception: _MaybeSequence[type[Exception]], + wait_gen: _WaitGenerator, + *, + max_tries: _MaybeCallable[int] | None, + max_time: _MaybeCallable[float] | None, + jitter: _Jitterer | None, + giveup: _Predicate[BaseException], + on_success: Iterable[_ContextHandler], + on_backoff: Iterable[_ContextHandler], + on_giveup: Iterable[_ContextHandler], + raise_on_giveup: bool, + wait_gen_kwargs: dict[str, Any], +) -> Generator[_Attempt, None, None]: + state = _RetryState( + wait_gen, + wait_gen_kwargs, + max_tries=max_tries, + max_time=max_time, + ) + while True: + state.start_attempt() + attempt = _Attempt(exception) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] + yield attempt + elapsed = state.record_elapsed() + + exc = attempt.exception + if exc is None: + _dispatch_handlers(on_success, tries=state.tries, elapsed=elapsed) + return + + if giveup(exc) or state.exhausted(): + _dispatch_handlers( + on_giveup, + tries=state.tries, + elapsed=elapsed, + exception=exc, + ) + if raise_on_giveup: + raise exc + return + + try: + seconds = state.next_wait(exc, jitter) + except StopIteration: + _dispatch_handlers( + on_giveup, + tries=state.tries, + elapsed=elapsed, + exception=exc, + ) + raise exc from None + + _dispatch_handlers( + on_backoff, + tries=state.tries, + elapsed=elapsed, + wait=seconds, + exception=exc, + ) + + time.sleep(seconds) diff --git a/backoff/_typing.py b/backoff/_typing.py index c084115..4dd24b1 100644 --- a/backoff/_typing.py +++ b/backoff/_typing.py @@ -29,6 +29,23 @@ class Details(_BaseDetails, _CallDetails, total=False): pass +class _BaseContextDetails(TypedDict): + tries: int + elapsed: float + + +class _ContextCallDetails(TypedDict, total=False): + wait: float # present in the on_backoff handler case + exception: Exception # present in the on_giveup handler case + + +class ContextDetails(_BaseContextDetails, _ContextCallDetails, total=False): + """Details passed to handlers registered with `retry_context`/`aretry_context`. + + Unlike `Details`, there's no wrapped callable, so no `target`/`args`/`kwargs`. + """ + + T = TypeVar("T") _CallableT = TypeVar("_CallableT", bound=Callable[..., Any]) # ruff:ignore[unused-private-type-var] @@ -36,6 +53,10 @@ class Details(_BaseDetails, _CallDetails, total=False): Callable[[Details], None], Callable[[Details], Coroutine[Any, Any, None]], ] +_ContextHandler = Union[ + Callable[[ContextDetails], None], + Callable[[ContextDetails], Coroutine[Any, Any, None]], +] _Jitterer = Callable[[float], float] _MaybeCallable = Union[T, Callable[[], T]] _MaybeLogger = Union[str, logging.Logger, logging.LoggerAdapter, None] diff --git a/docs/api/reference.md b/docs/api/reference.md index d8be69e..2d104e4 100644 --- a/docs/api/reference.md +++ b/docs/api/reference.md @@ -14,6 +14,18 @@ Complete API documentation for the backoff module. show_root_heading: true show_source: true +## Context Managers + +::: backoff.retry_context + options: + show_root_heading: true + show_source: true + +::: backoff.aretry_context + options: + show_root_heading: true + show_source: true + ## Wait Generators ::: backoff.expo diff --git a/docs/examples.md b/docs/examples.md index 3b271b9..eec8144 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -162,6 +162,30 @@ async def fetch_all(urls): return await asyncio.gather(*tasks, return_exceptions=True) ``` +## Context Manager + +```python +import requests +import requests.exceptions + +import backoff + +status = [500, 500, 200] + +for i, attempt in enumerate( + backoff.retry_context( + requests.exceptions.RequestException, + backoff.constant, + ) +): + print(f"Attempt {i + 1}") + with attempt: + response = requests.get(f"https://httpbin.org/status/{status[i]}") + response.raise_for_status() + +print(f"Response: {response.status_code}") +``` + ## Polling and Resource Waiting ### Poll for Job Completion diff --git a/docs/index.md b/docs/index.md index 33a41dc..a40b76a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -47,43 +47,9 @@ This will retry the function with exponential backoff whenever a `RequestExcepti ## Common Use Cases -### API Rate Limiting - -```python -@backoff.on_predicate( - backoff.runtime, - predicate=lambda r: r.status_code == 429, - value=lambda r: int(r.headers.get("Retry-After", 1)), - jitter=None, -) -def call_api(): - return requests.get(api_url) -``` - -### Database Retries - -```python -@backoff.on_exception( - backoff.expo, - sqlalchemy.exc.OperationalError, - max_tries=5, -) -def query_database(): - return session.query(Model).all() -``` - -### Polling for Results - -```python -@backoff.on_predicate( - backoff.constant, - lambda result: result is None, - interval=2, - max_time=300, -) -def poll_for_result(job_id): - return check_job_status(job_id) -``` +- [HTTP/API Calls](examples.md#httpapi-calls) +- [Database Operations](examples.md#database-operations) +- [Polling for Results](examples.md#polling-and-resource-waiting) ## Next Steps diff --git a/docs/user-guide/context-manager.md b/docs/user-guide/context-manager.md new file mode 100644 index 0000000..47703f3 --- /dev/null +++ b/docs/user-guide/context-manager.md @@ -0,0 +1,19 @@ +# Context Manager + +Backoff provides a context manager generator for non-decorator usage. + +## Basic Usage + +```python +import random + +import backoff + +for attempt in backoff.retry_context(): + with attempt: + choice = random.choice("Lorem ipsum dolor sit amet") + if choice not in "aeiou": + raise RuntimeError(f"Ah, no luck! (choice={choice})") + else: + print(f"Got it (choice={choice})") +``` diff --git a/tests/test_retry_context.py b/tests/test_retry_context.py new file mode 100644 index 0000000..9907781 --- /dev/null +++ b/tests/test_retry_context.py @@ -0,0 +1,348 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +import backoff + +if TYPE_CHECKING: + from backoff._typing import ContextDetails + + +def test_retry_context_success_after_failures() -> None: + calls = [] + + for attempt in backoff.retry_context( + ValueError, + backoff.constant, + interval=0, + max_tries=5, + jitter=None, + ): + with attempt: + calls.append(1) + if len(calls) < 3: + raise ValueError("nope") + + assert len(calls) == 3 + + +def test_retry_context_giveup_raises() -> None: + calls = [] + + def run() -> None: + for attempt in backoff.retry_context( + ValueError, + backoff.constant, + interval=0, + max_tries=3, + jitter=None, + ): + with attempt: + calls.append(1) + raise ValueError("always fails") + + with pytest.raises(ValueError, match="always fails"): + run() + + assert len(calls) == 3 + + +def test_retry_context_giveup_no_raise() -> None: + calls = [] + + for attempt in backoff.retry_context( + ValueError, + backoff.constant, + interval=0, + max_tries=3, + jitter=None, + raise_on_giveup=False, + ): + with attempt: + calls.append(1) + raise ValueError("always fails") + + assert len(calls) == 3 + + +def test_retry_context_unmatched_exception_propagates_immediately() -> None: + calls = [] + + def run() -> None: + for attempt in backoff.retry_context( + ValueError, + backoff.constant, + interval=0, + max_tries=5, + jitter=None, + ): + with attempt: + calls.append(1) + raise KeyError("wrong type") + + with pytest.raises(KeyError, match="wrong type"): + run() + + assert len(calls) == 1 + + +def test_retry_context_giveup_predicate() -> None: + calls = [] + + def run() -> None: + for attempt in backoff.retry_context( + ValueError, + backoff.constant, + interval=0, + max_tries=5, + jitter=None, + giveup=lambda e: True, + ): + with attempt: + calls.append(1) + raise ValueError("always fails") + + with pytest.raises(ValueError, match="always fails"): + run() + + assert len(calls) == 1 + + +def test_retry_context_handlers() -> None: + backoffs: list[ContextDetails] = [] + giveups: list[ContextDetails] = [] + successes: list[ContextDetails] = [] + + for attempt in backoff.retry_context( + ValueError, + backoff.constant, + interval=0, + max_tries=3, + jitter=None, + on_backoff=backoffs.append, + on_giveup=giveups.append, + on_success=successes.append, + ): + with attempt: + if len(backoffs) < 2: + raise ValueError("nope") + + assert len(backoffs) == 2 + assert len(giveups) == 0 + assert len(successes) == 1 + assert backoffs[0]["tries"] == 1 + assert isinstance(backoffs[0].get("exception"), ValueError) + + +def test_retry_context_wait_gen_exhausted() -> None: + calls = [] + giveups: list[ContextDetails] = [] + + def run() -> None: + for attempt in backoff.retry_context( + ValueError, + backoff.constant, + interval=(0,), + jitter=None, + on_giveup=giveups.append, + ): + with attempt: + calls.append(1) + raise ValueError("always fails") + + with pytest.raises(ValueError, match="always fails"): + run() + + assert len(calls) == 2 + assert len(giveups) == 1 + assert isinstance(giveups[0].get("exception"), ValueError) + + +def test_retry_context_wait_gen_exhausted_always_raises() -> None: + calls = [] + giveups: list[ContextDetails] = [] + + def run() -> None: + for attempt in backoff.retry_context( + ValueError, + backoff.constant, + interval=(0,), + jitter=None, + on_giveup=giveups.append, + raise_on_giveup=False, + ): + with attempt: + calls.append(1) + raise ValueError("always fails") + + with pytest.raises(ValueError, match="always fails"): + run() + + assert len(calls) == 2 + assert len(giveups) == 1 + assert isinstance(giveups[0].get("exception"), ValueError) + + +@pytest.mark.asyncio +async def test_aretry_context_success_after_failures() -> None: + calls = [] + + async for attempt in backoff.aretry_context( + ValueError, + backoff.constant, + interval=0, + max_tries=5, + jitter=None, + ): + with attempt: + calls.append(1) + if len(calls) < 3: + raise ValueError("nope") + + assert len(calls) == 3 + + +@pytest.mark.asyncio +async def test_aretry_context_giveup_raises() -> None: + calls = [] + + async def run() -> None: + async for attempt in backoff.aretry_context( + ValueError, + backoff.constant, + interval=0, + max_tries=3, + jitter=None, + ): + with attempt: + calls.append(1) + raise ValueError("always fails") + + with pytest.raises(ValueError, match="always fails"): + await run() + + assert len(calls) == 3 + + +@pytest.mark.asyncio +async def test_aretry_context_async_giveup_predicate() -> None: + calls = [] + + async def agiveup(exc: BaseException) -> bool: + return True + + async def run() -> None: + async for attempt in backoff.aretry_context( + ValueError, + backoff.constant, + interval=0, + max_tries=5, + jitter=None, + giveup=agiveup, + ): + with attempt: + calls.append(1) + raise ValueError("always fails") + + with pytest.raises(ValueError, match="always fails"): + await run() + + assert len(calls) == 1 + + +@pytest.mark.asyncio +async def test_aretry_context_async_handlers() -> None: + backoffs = [] + + async def on_backoff(details) -> None: + backoffs.append(details) + + calls = [] + async for attempt in backoff.aretry_context( + ValueError, + backoff.constant, + interval=0, + max_tries=5, + jitter=None, + on_backoff=on_backoff, + ): + with attempt: + calls.append(1) + if len(calls) < 3: + raise ValueError("nope") + + assert len(backoffs) == 2 + assert backoffs[0]["tries"] == 1 + assert isinstance(backoffs[0]["exception"], ValueError) + + +@pytest.mark.asyncio +async def test_aretry_context_async_giveup_no_raise() -> None: + calls = [] + + async for attempt in backoff.aretry_context( + ValueError, + backoff.constant, + interval=0, + max_tries=3, + jitter=None, + raise_on_giveup=False, + ): + with attempt: + calls.append(1) + raise ValueError("always fails") + + assert len(calls) == 3 + + +@pytest.mark.asyncio +async def test_aretry_context_wait_gen_exhausted() -> None: + calls = [] + giveups: list[ContextDetails] = [] + + async def run() -> None: + async for attempt in backoff.aretry_context( + ValueError, + backoff.constant, + interval=(0,), + jitter=None, + on_giveup=giveups.append, + ): + with attempt: + calls.append(1) + raise ValueError("always fails") + + with pytest.raises(ValueError, match="always fails"): + await run() + + assert len(calls) == 2 + assert len(giveups) == 1 + assert isinstance(giveups[0].get("exception"), ValueError) + + +@pytest.mark.asyncio +async def test_aretry_context_wait_gen_exhausted_always_raises() -> None: + calls = [] + giveups: list[ContextDetails] = [] + + async def run() -> None: + async for attempt in backoff.aretry_context( + ValueError, + backoff.constant, + interval=(0,), + jitter=None, + on_giveup=giveups.append, + raise_on_giveup=False, + ): + with attempt: + calls.append(1) + raise ValueError("always fails") + + with pytest.raises(ValueError, match="always fails"): + await run() + + assert len(calls) == 2 + assert len(giveups) == 1 + assert isinstance(giveups[0].get("exception"), ValueError) diff --git a/zensical.toml b/zensical.toml index 3e4893b..165db0d 100644 --- a/zensical.toml +++ b/zensical.toml @@ -9,6 +9,7 @@ nav = [ { "Getting Started" = "getting-started.md" }, { "User Guide" = [ { "Decorators" = "user-guide/decorators.md" }, + { "Context Manager" = "user-guide/context-manager.md" }, { "Wait Strategies" = "user-guide/wait-strategies.md" }, { "Configuration" = "user-guide/configuration.md" }, { "Event Handlers" = "user-guide/event-handlers.md" },