diff --git a/backoff/_async.py b/backoff/_async.py index 006a898..f677424 100644 --- a/backoff/_async.py +++ b/backoff/_async.py @@ -91,11 +91,13 @@ def retry_predicate( max_tries: _MaybeCallable[int] | None, max_time: _MaybeCallable[float] | None, jitter: _Jitterer | None, + on_try: Iterable[_Handler], on_success: Iterable[_Handler], on_backoff: Iterable[_Handler], on_giveup: Iterable[_Handler], wait_gen_kwargs: dict[str, Any], ) -> Callable[P, T]: + on_try = _ensure_coroutines(on_try) on_success = _ensure_coroutines(on_success) on_backoff = _ensure_coroutines(on_backoff) on_giveup = _ensure_coroutines(on_giveup) @@ -116,14 +118,16 @@ async def retry(*args: P.args, **kwargs: P.kwargs) -> T: ) while True: state.start_attempt() - ret = await target(*args, **kwargs) details: _BaseDetails = { "target": target, "args": args, "kwargs": kwargs, "tries": state.tries, - "elapsed": state.record_elapsed(), + "elapsed": state.elapsed, } + await _call_handlers(on_try, **details) + ret = await target(*args, **kwargs) + details["elapsed"] = state.record_elapsed() if predicate(ret): if state.exhausted(): @@ -185,12 +189,14 @@ def retry_exception( max_time: _MaybeCallable[float] | None, jitter: _Jitterer | None, giveup: _Predicate[Exception], + on_try: Iterable[_Handler], on_success: Iterable[_Handler], on_backoff: Iterable[_Handler], on_giveup: Iterable[_Handler], raise_on_giveup: bool, wait_gen_kwargs: dict[str, Any], ) -> Callable[P, T]: + on_try = _ensure_coroutines(on_try) on_success = _ensure_coroutines(on_success) on_backoff = _ensure_coroutines(on_backoff) on_giveup = _ensure_coroutines(on_giveup) @@ -214,6 +220,7 @@ async def retry( max_time=max_time, jitter=jitter, giveup=giveup, + on_try=_adapt_context_handlers(on_try, target, args, kwargs), on_success=_adapt_context_handlers(on_success, target, args, kwargs), on_backoff=_adapt_context_handlers(on_backoff, target, args, kwargs), on_giveup=_adapt_context_handlers(on_giveup, target, args, kwargs), @@ -243,6 +250,7 @@ async def aretry_context( max_time: _MaybeCallable[float] | None, jitter: _Jitterer | None, giveup: _Predicate[BaseException], + on_try: Iterable[_ContextHandler], on_success: Iterable[_ContextHandler], on_backoff: Iterable[_ContextHandler], on_giveup: Iterable[_ContextHandler], @@ -260,6 +268,11 @@ async def aretry_context( while True: state.start_attempt() attempt = _Attempt(exception) + await _dispatch_handlers( + handlers=on_try, + tries=state.tries, + elapsed=state.elapsed, + ) yield attempt elapsed = state.record_elapsed() diff --git a/backoff/_decorator.py b/backoff/_decorator.py index ae7e801..77889a8 100644 --- a/backoff/_decorator.py +++ b/backoff/_decorator.py @@ -49,6 +49,7 @@ def on_predicate( max_tries: _MaybeCallable[int] | None = None, max_time: _MaybeCallable[float] | None = None, jitter: _Jitterer | None = full_jitter, + on_try: _Handler | Iterable[_Handler] | None = None, on_success: _Handler | Iterable[_Handler] | None = None, on_backoff: _Handler | Iterable[_Handler] | None = None, on_giveup: _Handler | Iterable[_Handler] | None = None, @@ -81,6 +82,9 @@ def on_predicate( concurrent clients. Wait times are jittered by default using the full_jitter function. Jittering may be disabled altogether by passing jitter=None. + on_try: Callable (or iterable of callables) with a unary + signature to be called before each attempt. The parameter + is a dict containing details about the invocation. on_success: Callable (or iterable of callables) with a unary signature to be called in the event of success. The parameter is a dict containing details about the invocation. @@ -102,9 +106,10 @@ def on_predicate( """ def decorate(target: Callable[P, T]) -> Callable[P, T]: - nonlocal logger, on_success, on_backoff, on_giveup + nonlocal logger, on_try, on_success, on_backoff, on_giveup logger = _prepare_logger(logger) + on_try = _config_handlers(on_try) on_success = _config_handlers(on_success) on_backoff = _config_handlers( on_backoff, @@ -131,6 +136,7 @@ def decorate(target: Callable[P, T]) -> Callable[P, T]: max_tries=max_tries, max_time=max_time, jitter=jitter, + on_try=on_try, on_success=on_success, on_backoff=on_backoff, on_giveup=on_giveup, @@ -149,6 +155,7 @@ def on_exception( max_time: _MaybeCallable[float] | None = None, jitter: _Jitterer | None = full_jitter, giveup: _Predicate[Exception] = lambda e: False, + on_try: _Handler | Iterable[_Handler] | None = None, on_success: _Handler | Iterable[_Handler] | None = None, on_backoff: _Handler | Iterable[_Handler] | None = None, on_giveup: _Handler | Iterable[_Handler] | None = None, @@ -183,6 +190,9 @@ def on_exception( giveup: Function accepting an exception instance and returning whether or not to give up. Optional. The default is to always continue. + on_try: Callable (or iterable of callables) with a unary + signature to be called before each attempt. The parameter + is a dict containing details about the invocation. on_success: Callable (or iterable of callables) with a unary signature to be called in the event of success. The parameter is a dict containing details about the invocation. @@ -205,9 +215,10 @@ def on_exception( """ def decorate(target: Callable[P, T]) -> Callable[P, T]: - nonlocal logger, on_success, on_backoff, on_giveup + nonlocal logger, on_try, on_success, on_backoff, on_giveup logger = _prepare_logger(logger) + on_try = _config_handlers(on_try) on_success = _config_handlers(on_success) on_backoff = _config_handlers( on_backoff, @@ -235,6 +246,7 @@ def decorate(target: Callable[P, T]) -> Callable[P, T]: max_time=max_time, jitter=jitter, giveup=giveup, + on_try=on_try, on_success=on_success, on_backoff=on_backoff, on_giveup=on_giveup, @@ -254,6 +266,7 @@ def retry_context( max_time: _MaybeCallable[float] | None = None, jitter: _Jitterer | None = full_jitter, giveup: _Predicate[BaseException] = lambda e: False, + on_try: _ContextHandler | Iterable[_ContextHandler] | None = None, on_success: _ContextHandler | Iterable[_ContextHandler] | None = None, on_backoff: _ContextHandler | Iterable[_ContextHandler] | None = None, on_giveup: _ContextHandler | Iterable[_ContextHandler] | None = None, @@ -313,6 +326,7 @@ def retry_context( passed to wait_gen when it is initialized. """ logger = _prepare_logger(logger) + on_try = _config_handlers(on_try) on_success = _config_handlers(on_success) on_backoff = _config_handlers( on_backoff, @@ -334,6 +348,7 @@ def retry_context( max_time=max_time, jitter=jitter, giveup=giveup, + on_try=on_try, on_success=on_success, on_backoff=on_backoff, on_giveup=on_giveup, @@ -350,6 +365,7 @@ def aretry_context( max_time: _MaybeCallable[float] | None = None, jitter: _Jitterer | None = full_jitter, giveup: _Predicate[BaseException] = lambda e: False, + on_try: _ContextHandler | Iterable[_ContextHandler] | None = None, on_success: _ContextHandler | Iterable[_ContextHandler] | None = None, on_backoff: _ContextHandler | Iterable[_ContextHandler] | None = None, on_giveup: _ContextHandler | Iterable[_ContextHandler] | None = None, @@ -369,6 +385,7 @@ def aretry_context( `retry_context` for the full argument reference. """ logger = _prepare_logger(logger) + on_try = _config_handlers(on_try) on_success = _config_handlers(on_success) on_backoff = _config_handlers( on_backoff, @@ -390,6 +407,7 @@ def aretry_context( max_time=max_time, jitter=jitter, giveup=giveup, + on_try=on_try, on_success=on_success, on_backoff=on_backoff, on_giveup=on_giveup, diff --git a/backoff/_sync.py b/backoff/_sync.py index 6c39f9e..3c6bc1a 100644 --- a/backoff/_sync.py +++ b/backoff/_sync.py @@ -68,6 +68,7 @@ def retry_predicate( max_tries: _MaybeCallable[int] | None, max_time: _MaybeCallable[float] | None, jitter: _Jitterer | None, + on_try: Iterable[_Handler], on_success: Iterable[_Handler], on_backoff: Iterable[_Handler], on_giveup: Iterable[_Handler], @@ -83,14 +84,16 @@ def retry(*args: P.args, **kwargs: P.kwargs) -> T: ) while True: state.start_attempt() - ret = target(*args, **kwargs) details: _BaseDetails = { "target": target, "args": args, "kwargs": kwargs, "tries": state.tries, - "elapsed": state.record_elapsed(), + "elapsed": state.elapsed, } + _call_handlers(on_try, **details) + ret = target(*args, **kwargs) + details["elapsed"] = state.record_elapsed() if predicate(ret): if state.exhausted(): @@ -143,6 +146,7 @@ def retry_exception( max_time: _MaybeCallable[float] | None, jitter: _Jitterer | None, giveup: _Predicate[Exception], + on_try: Iterable[_Handler], on_success: Iterable[_Handler], on_backoff: Iterable[_Handler], on_giveup: Iterable[_Handler], @@ -160,6 +164,7 @@ def retry(*args: P.args, **kwargs: P.kwargs) -> T: max_time=max_time, jitter=jitter, giveup=giveup, # type: ignore[arg-type] # ty:ignore[invalid-argument-type] + on_try=_adapt_context_handlers(on_try, target, args, kwargs), on_success=_adapt_context_handlers(on_success, target, args, kwargs), on_backoff=_adapt_context_handlers(on_backoff, target, args, kwargs), on_giveup=_adapt_context_handlers(on_giveup, target, args, kwargs), @@ -182,6 +187,7 @@ def retry_context( max_time: _MaybeCallable[float] | None, jitter: _Jitterer | None, giveup: _Predicate[BaseException], + on_try: Iterable[_ContextHandler], on_success: Iterable[_ContextHandler], on_backoff: Iterable[_ContextHandler], on_giveup: Iterable[_ContextHandler], @@ -197,6 +203,7 @@ def retry_context( while True: state.start_attempt() attempt = _Attempt(exception) + _dispatch_handlers(handlers=on_try, tries=state.tries, elapsed=state.elapsed) yield attempt elapsed = state.record_elapsed() diff --git a/docs/faq.md b/docs/faq.md index c2d23a0..32beb9a 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -194,7 +194,7 @@ async def fetch_data(url): ### Can event handlers be async? -Yes, you can use async functions for `on_success`, `on_backoff`, and `on_giveup`: +Yes, you can use async functions for `on_success`, `on_backoff`, `on_giveup` and `on_try`: ```python async def log_retry(details): diff --git a/docs/index.md b/docs/index.md index a40b76a..18d6f60 100644 --- a/docs/index.md +++ b/docs/index.md @@ -13,7 +13,7 @@ This module provides function decorators which can be used to wrap a function su - **Simple decorators** - Easy-to-use `@backoff.on_exception` and `@backoff.on_predicate` decorators - **Multiple wait strategies** - Exponential, fibonacci, constant, and runtime-configurable strategies - **Flexible configuration** - Control retry limits with `max_time`, `max_tries`, and custom give-up conditions -- **Event handlers** - Hook into retry lifecycle with `on_success`, `on_backoff`, and `on_giveup` callbacks +- **Event handlers** - Hook into retry lifecycle with `on_success`, `on_backoff`, `on_giveup` and `on_try` callbacks - **Async support** - Full support for `asyncio` coroutines - **Type hints** - Fully typed for better IDE support - **Battle-tested** - Used in production by thousands of projects diff --git a/docs/user-guide/decorators.md b/docs/user-guide/decorators.md index 3c1535e..301ca89 100644 --- a/docs/user-guide/decorators.md +++ b/docs/user-guide/decorators.md @@ -33,6 +33,7 @@ def get_url(url): - **on_success** - Callback when function succeeds - **on_backoff** - Callback when backing off - **on_giveup** - Callback when giving up +- **on_try** - Callback for every attempt - **raise_on_giveup** - Whether to raise exception on giveup (default: True) - **logger** - Logger for retry events (default: 'backoff' logger) @@ -123,6 +124,7 @@ def poll_for_result(job_id): - **on_success** - Callback when predicate returns False - **on_backoff** - Callback when predicate returns True - **on_giveup** - Callback when giving up +- **on_try** - Called for every attempt - **logger** - Logger for retry events (default: 'backoff' logger) ### Default Predicate (Falsey Check) diff --git a/docs/user-guide/event-handlers.md b/docs/user-guide/event-handlers.md index 0b25d21..a6504af 100644 --- a/docs/user-guide/event-handlers.md +++ b/docs/user-guide/event-handlers.md @@ -9,6 +9,7 @@ Backoff decorators accept three types of event handlers: - **on_success** - Called when function succeeds - **on_backoff** - Called before each retry wait - **on_giveup** - Called when all retries are exhausted +- **on_try** - Called for every attempt ## Handler Signature @@ -134,6 +135,25 @@ def my_function(): pass ``` +## on_try Handler + +Called for every attempt. + +```python +def log_try(details): + print(f"Attempt {details['tries']}: on {details['target'].__name__}") + + +@backoff.on_exception( + backoff.expo, + Exception, + on_try=log_try, + max_tries=5, +) +def my_function(): + pass +``` + ## Multiple Handlers You can provide multiple handlers as a list: diff --git a/tests/common.py b/tests/common.py index 5313381..877cce5 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1,8 +1,7 @@ from __future__ import annotations -import collections -import functools -from typing import TYPE_CHECKING, Callable, TypeVar +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Callable, Literal, TypeVar if TYPE_CHECKING: import sys @@ -11,35 +10,39 @@ from backoff._typing import Details if sys.version_info >= (3, 10): - from typing import ParamSpec + from typing import ParamSpec, TypeAlias else: - from typing_extensions import ParamSpec + from typing_extensions import ParamSpec, TypeAlias + + Event: TypeAlias = Literal["backoff", "giveup", "success", "try"] + Events: TypeAlias = dict[Event, list[Details]] T = TypeVar("T") P = ParamSpec("P") -# create event handler which log their invocations to a dict -def _log_hdlrs() -> tuple[ - collections.defaultdict[str, list[Details]], - Callable[[Details], None], - Callable[[Details], None], - Callable[[Details], None], -]: - log = collections.defaultdict(list) - - def log_hdlr(event: str, details: Details): - log[event].append(details) - - log_success = functools.partial(log_hdlr, "success") - log_backoff = functools.partial(log_hdlr, "backoff") - log_giveup = functools.partial(log_hdlr, "giveup") - - return log, log_success, log_backoff, log_giveup - - # decorator that that saves the target as # an attribute of the decorated function def _save_target(f: Callable[P, T]) -> Callable[P, T]: f._target = f # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] return f + + +def _init_events() -> Events: + return { + "backoff": [], + "giveup": [], + "success": [], + "try": [], + } + + +@dataclass +class EventAppender: + events: Events = field(default_factory=_init_events) + + def on_event(self, event: Event) -> Callable[[Details], None]: + return self.events[event].append + + def counts(self) -> dict[Event, int]: + return {k: len(v) for k, v in self.events.items()} diff --git a/tests/conftest.py b/tests/conftest.py index 211a2ef..dbe8da5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,35 +1,8 @@ from __future__ import annotations -from collections import defaultdict -from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Literal - import pytest -if TYPE_CHECKING: - import sys - - from backoff._typing import Details - - if sys.version_info >= (3, 10): - from typing import TypeAlias - else: - from typing_extensions import TypeAlias - - Event: TypeAlias = Literal["backoff", "giveup", "success"] - - -@dataclass -class EventAppender: - events: dict[Event, list[Details]] = field( - default_factory=lambda: defaultdict(list) - ) - - def on_event(self, event: Event): - return self.events[event].append - - def counts(self) -> dict[Event, int]: - return {k: len(v) for k, v in self.events.items()} +from tests.common import EventAppender @pytest.fixture diff --git a/tests/test_backoff.py b/tests/test_backoff.py index ca5e3bb..4a99adf 100644 --- a/tests/test_backoff.py +++ b/tests/test_backoff.py @@ -13,13 +13,12 @@ from dirty_equals import IsFloat, IsInstance import backoff -from tests.common import _save_target +from tests.common import EventAppender, _save_target if TYPE_CHECKING: from collections.abc import Generator from backoff._typing import Details - from tests.conftest import EventAppender @pytest.fixture(autouse=True) @@ -103,7 +102,10 @@ def lookup_max_time(): return 10 @backoff.on_predicate( - backoff.expo, jitter=None, max_time=lookup_max_time, on_giveup=giveup + backoff.expo, + jitter=None, + max_time=lookup_max_time, + on_giveup=giveup, ) def return_true(log: list[bool], n): val = len(log) == n @@ -183,12 +185,10 @@ def keyerror_then_true(log: list[Exception], n, foo=None): def test_on_exception_constant_iterable() -> None: - backoffs: list[Details] = [] - giveups: list[Details] = [] - successes: list[Details] = [] + appender = EventAppender() def on_backoff(details: Details): - nonlocal backoffs + backoffs = appender.events["backoff"] assert details["tries"] == len(backoffs) + 1 assert "exception" in details assert isinstance(details["exception"], KeyError) @@ -196,7 +196,7 @@ def on_backoff(details: Details): backoffs.append(details) def on_giveup(details: Details): - nonlocal giveups + giveups = appender.events["giveup"] assert details["tries"] == 4 assert "exception" in details assert isinstance(details["exception"], KeyError) @@ -204,10 +204,15 @@ def on_giveup(details: Details): giveups.append(details) def on_success(details: Details): - nonlocal successes + successes = appender.events["success"] successes.append(details) + def on_try(details: Details): + tries = appender.events["try"] + + tries.append(details) + @backoff.on_exception( backoff.constant, KeyError, @@ -215,6 +220,7 @@ def on_success(details: Details): on_backoff=on_backoff, on_giveup=on_giveup, on_success=on_success, + on_try=on_try, ) def endless_exceptions(): raise KeyError("foo") @@ -222,9 +228,12 @@ def endless_exceptions(): with pytest.raises(KeyError): endless_exceptions() - assert len(backoffs) == 3 - assert len(giveups) == 1 - assert len(successes) == 0 + assert appender.counts() == { + "backoff": 3, + "giveup": 1, + "success": 0, + "try": 4, + } def test_on_exception_success_random_jitter(appender: EventAppender) -> None: @@ -234,6 +243,7 @@ def test_on_exception_success_random_jitter(appender: EventAppender) -> None: on_backoff=appender.on_event("backoff"), on_giveup=appender.on_event("giveup"), on_success=appender.on_event("success"), + on_try=appender.on_event("try"), jitter=backoff.random_jitter, factor=0.5, ) @@ -250,6 +260,7 @@ def succeeder(*args, **kwargs): "backoff": 2, "giveup": 0, "success": 1, + "try": 3, } for i in range(2): @@ -264,6 +275,7 @@ def test_on_exception_success_full_jitter(appender: EventAppender) -> None: on_backoff=appender.on_event("backoff"), on_giveup=appender.on_event("giveup"), on_success=appender.on_event("success"), + on_try=appender.on_event("try"), jitter=backoff.full_jitter, factor=0.5, ) @@ -280,6 +292,7 @@ def succeeder(*args, **kwargs): "backoff": 2, "giveup": 0, "success": 1, + "try": 3, } for i in range(2): @@ -294,6 +307,7 @@ def test_on_exception_success(appender: EventAppender) -> None: on_backoff=appender.on_event("backoff"), on_giveup=appender.on_event("giveup"), on_success=appender.on_event("success"), + on_try=appender.on_event("try"), jitter=None, interval=0, ) @@ -310,6 +324,7 @@ def succeeder(*args, **kwargs): "backoff": 2, "giveup": 0, "success": 1, + "try": 3, } for i in range(2): @@ -334,6 +349,38 @@ def succeeder(*args, **kwargs): } +def test_on_exception_on_try_runs_before_attempt() -> None: + calls: list[object] = [] + + @backoff.on_exception( + backoff.constant, + ValueError, + on_try=lambda details: calls.append(( + "try", + details["tries"], + details["elapsed"], + )), + jitter=None, + interval=0, + max_tries=3, + ) + def fails(): + calls.append("call") + raise ValueError("nope") + + with pytest.raises(ValueError, match="nope"): + fails() + + assert calls == [ + ("try", 1, 0), + "call", + ("try", 2, IsFloat(gt=0)), + "call", + ("try", 3, IsFloat(gt=0)), + "call", + ] + + @pytest.mark.parametrize("raise_on_giveup", [True, False]) def test_on_exception_giveup(raise_on_giveup: bool, appender: EventAppender) -> None: @backoff.on_exception( @@ -342,6 +389,7 @@ def test_on_exception_giveup(raise_on_giveup: bool, appender: EventAppender) -> on_backoff=appender.on_event("backoff"), on_giveup=appender.on_event("giveup"), on_success=appender.on_event("success"), + on_try=appender.on_event("try"), max_tries=3, jitter=None, raise_on_giveup=raise_on_giveup, @@ -362,6 +410,7 @@ def exceptor(*args, **kwargs): "backoff": 2, "giveup": 1, "success": 0, + "try": 3, } details = appender.events["giveup"][0] @@ -397,6 +446,7 @@ def test_on_predicate_success(appender: EventAppender) -> None: on_backoff=appender.on_event("backoff"), on_giveup=appender.on_event("giveup"), on_success=appender.on_event("success"), + on_try=appender.on_event("try"), jitter=None, interval=0, ) @@ -412,6 +462,7 @@ def success(*args, **kwargs): "backoff": 2, "giveup": 0, "success": 1, + "try": 3, } for i in range(2): @@ -438,12 +489,43 @@ def success(*args, **kwargs): } +def test_on_predicate_on_try_runs_before_attempt() -> None: + calls: list[object] = [] + + @backoff.on_predicate( + backoff.constant, + on_try=lambda details: calls.append(( + "try", + details["tries"], + details["elapsed"], + )), + jitter=None, + interval=0, + max_tries=3, + ) + def falsey(): + calls.append("call") + return False + + falsey() + + assert calls == [ + ("try", 1, 0), + "call", + ("try", 2, IsFloat(gt=0)), + "call", + ("try", 3, IsFloat(gt=0)), + "call", + ] + + def test_on_predicate_giveup(appender: EventAppender) -> None: @backoff.on_predicate( backoff.constant, on_backoff=appender.on_event("backoff"), on_giveup=appender.on_event("giveup"), on_success=appender.on_event("success"), + on_try=appender.on_event("try"), max_tries=3, jitter=None, interval=0, @@ -459,6 +541,7 @@ def emptiness(*args, **kwargs): "backoff": 2, "giveup": 1, "success": 0, + "try": 3, } details = appender.events["giveup"][0] @@ -475,17 +558,16 @@ def emptiness(*args, **kwargs): def test_on_predicate_iterable_handlers() -> None: class Logger: def __init__(self): - self.backoffs: list[Details] = [] - self.giveups: list[Details] = [] - self.successes: list[Details] = [] + self.appender = EventAppender() loggers = [Logger() for _ in range(3)] @backoff.on_predicate( backoff.constant, - on_backoff=(l.backoffs.append for l in loggers), - on_giveup=(l.giveups.append for l in loggers), - on_success=(l.successes.append for l in loggers), + on_backoff=(l.appender.on_event("backoff") for l in loggers), + on_giveup=(l.appender.on_event("giveup") for l in loggers), + on_success=(l.appender.on_event("success") for l in loggers), + on_try=(l.appender.on_event("try") for l in loggers), max_tries=3, jitter=None, interval=0, @@ -497,11 +579,14 @@ def emptiness(*args, **kwargs): emptiness(1, 2, 3, foo=1, bar=2) for logger in loggers: - assert len(logger.successes) == 0 - assert len(logger.backoffs) == 2 - assert len(logger.giveups) == 1 + assert logger.appender.counts() == { + "backoff": 2, + "giveup": 1, + "success": 0, + "try": 3, + } - details = logger.giveups[0] + details = logger.appender.events["giveup"][0] assert details == { "args": (1, 2, 3), "kwargs": {"foo": 1, "bar": 2}, @@ -519,6 +604,7 @@ def test_on_exception_jitter(appender: EventAppender) -> None: on_backoff=appender.on_event("backoff"), on_giveup=appender.on_event("giveup"), on_success=appender.on_event("success"), + on_try=appender.on_event("try"), jitter=lambda value: 0.0, interval=0, ) @@ -535,6 +621,7 @@ def succeeder(*args, **kwargs): "backoff": 2, "giveup": 0, "success": 1, + "try": 3, } for i in range(2): @@ -565,6 +652,7 @@ def test_on_predicate_jitter(appender: EventAppender) -> None: on_backoff=appender.on_event("backoff"), on_giveup=appender.on_event("giveup"), on_success=appender.on_event("success"), + on_try=appender.on_event("try"), jitter=lambda value: 0.0, interval=0, ) @@ -580,6 +668,7 @@ def success(*args, **kwargs): "backoff": 2, "giveup": 0, "success": 1, + "try": 3, } for i in range(2): @@ -697,6 +786,7 @@ def test_on_predicate_constant_iterable(appender: EventAppender) -> None: on_backoff=appender.on_event("backoff"), on_giveup=appender.on_event("giveup"), on_success=appender.on_event("success"), + on_try=appender.on_event("try"), jitter=None, ) def falsey(): @@ -707,6 +797,7 @@ def falsey(): "backoff": len(waits), "giveup": 1, "success": 0, + "try": 6, } for i, wait in enumerate(waits): diff --git a/tests/test_backoff_async.py b/tests/test_backoff_async.py index e1fe99c..134681d 100644 --- a/tests/test_backoff_async.py +++ b/tests/test_backoff_async.py @@ -9,13 +9,11 @@ from dirty_equals import IsFloat, IsInstance import backoff -from tests.common import _log_hdlrs, _save_target +from tests.common import EventAppender, _save_target if TYPE_CHECKING: from collections.abc import Generator - from tests.conftest import EventAppender - asyncio_sleep = asyncio.sleep @@ -150,6 +148,7 @@ async def test_on_exception_constant_iterable(appender: EventAppender) -> None: on_backoff=appender.on_event("backoff"), on_giveup=appender.on_event("giveup"), on_success=appender.on_event("success"), + on_try=appender.on_event("try"), ) async def endless_exceptions(): raise KeyError("foo") @@ -161,101 +160,106 @@ async def endless_exceptions(): "backoff": 3, "giveup": 1, "success": 0, + "try": 4, } @pytest.mark.asyncio -async def test_on_exception_success_random_jitter() -> None: - - log, log_success, log_backoff, log_giveup = _log_hdlrs() - +async def test_on_exception_success_random_jitter(appender: EventAppender) -> None: @backoff.on_exception( backoff.expo, Exception, - on_success=log_success, - on_backoff=log_backoff, - on_giveup=log_giveup, + on_backoff=appender.on_event("backoff"), + on_giveup=appender.on_event("giveup"), + on_success=appender.on_event("success"), + on_try=appender.on_event("try"), jitter=backoff.random_jitter, factor=0.5, ) @_save_target async def succeeder(*args, **kwargs): # succeed after we've backed off twice - if len(log["backoff"]) < 2: + if len(appender.events["backoff"]) < 2: raise ValueError("catch me") await succeeder(1, 2, 3, foo=1, bar=2) # we try 3 times, backing off twice before succeeding - assert len(log["success"]) == 1 - assert len(log["backoff"]) == 2 - assert len(log["giveup"]) == 0 + assert appender.counts() == { + "backoff": 2, + "giveup": 0, + "success": 1, + "try": 3, + } for i in range(2): - details = log["backoff"][i] + details = appender.events["backoff"][i] assert details["wait"] >= 0.5 * 2**i @pytest.mark.asyncio -async def test_on_exception_success_full_jitter() -> None: - - log, log_success, log_backoff, log_giveup = _log_hdlrs() - +async def test_on_exception_success_full_jitter(appender: EventAppender) -> None: @backoff.on_exception( backoff.expo, Exception, - on_success=log_success, - on_backoff=log_backoff, - on_giveup=log_giveup, + on_backoff=appender.on_event("backoff"), + on_giveup=appender.on_event("giveup"), + on_success=appender.on_event("success"), + on_try=appender.on_event("try"), jitter=backoff.full_jitter, factor=0.5, ) @_save_target async def succeeder(*args, **kwargs): # succeed after we've backed off twice - if len(log["backoff"]) < 2: + if len(appender.events["backoff"]) < 2: raise ValueError("catch me") await succeeder(1, 2, 3, foo=1, bar=2) # we try 3 times, backing off twice before succeeding - assert len(log["success"]) == 1 - assert len(log["backoff"]) == 2 - assert len(log["giveup"]) == 0 + assert appender.counts() == { + "backoff": 2, + "giveup": 0, + "success": 1, + "try": 3, + } for i in range(2): - details = log["backoff"][i] + details = appender.events["backoff"][i] assert details["wait"] <= 0.5 * 2**i @pytest.mark.asyncio -async def test_on_exception_success() -> None: - log, log_success, log_backoff, log_giveup = _log_hdlrs() - +async def test_on_exception_success(appender: EventAppender) -> None: @backoff.on_exception( backoff.constant, Exception, - on_success=log_success, - on_backoff=log_backoff, - on_giveup=log_giveup, + on_backoff=appender.on_event("backoff"), + on_giveup=appender.on_event("giveup"), + on_success=appender.on_event("success"), + on_try=appender.on_event("try"), jitter=None, interval=0, ) @_save_target async def succeeder(*args, **kwargs): # succeed after we've backed off twice - if len(log["backoff"]) < 2: + if len(appender.events["backoff"]) < 2: raise ValueError("catch me") await succeeder(1, 2, 3, foo=1, bar=2) # we try 3 times, backing off twice before succeeding - assert len(log["success"]) == 1 - assert len(log["backoff"]) == 2 - assert len(log["giveup"]) == 0 + assert appender.counts() == { + "backoff": 2, + "giveup": 0, + "success": 1, + "try": 3, + } for i in range(2): - details = log["backoff"][i] + details = appender.events["backoff"][i] assert details == { "args": (1, 2, 3), "kwargs": {"foo": 1, "bar": 2}, @@ -266,7 +270,7 @@ async def succeeder(*args, **kwargs): "exception": IsInstance(ValueError), } - details = log["success"][0] + details = appender.events["success"][0] assert details == { "args": (1, 2, 3), "kwargs": {"foo": 1, "bar": 2}, @@ -277,16 +281,51 @@ async def succeeder(*args, **kwargs): @pytest.mark.asyncio -@pytest.mark.parametrize("raise_on_giveup", [True, False]) -async def test_on_exception_giveup(raise_on_giveup: bool) -> None: - log, log_success, log_backoff, log_giveup = _log_hdlrs() +async def test_on_exception_on_try_runs_before_attempt() -> None: + calls: list[object] = [] + + @backoff.on_exception( + backoff.constant, + ValueError, + on_try=lambda details: calls.append(( + "try", + details["tries"], + details["elapsed"], + )), + jitter=None, + interval=0, + max_tries=3, + ) + async def fails(): + calls.append("call") + raise ValueError("nope") + + with pytest.raises(ValueError, match="nope"): + await fails() + assert calls == [ + ("try", 1, 0), + "call", + ("try", 2, IsFloat(gt=0)), + "call", + ("try", 3, IsFloat(gt=0)), + "call", + ] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("raise_on_giveup", [True, False]) +async def test_on_exception_giveup( + raise_on_giveup: bool, + appender: EventAppender, +) -> None: @backoff.on_exception( backoff.constant, ValueError, - on_success=log_success, - on_backoff=log_backoff, - on_giveup=log_giveup, + on_backoff=appender.on_event("backoff"), + on_giveup=appender.on_event("giveup"), + on_success=appender.on_event("success"), + on_try=appender.on_event("try"), raise_on_giveup=raise_on_giveup, max_tries=3, jitter=None, @@ -303,11 +342,14 @@ async def exceptor(*args, **kwargs): await exceptor(1, 2, 3, foo=1, bar=2) # we try 3 times, backing off twice and giving up once - assert len(log["success"]) == 0 - assert len(log["backoff"]) == 2 - assert len(log["giveup"]) == 1 + assert appender.counts() == { + "backoff": 2, + "giveup": 1, + "success": 0, + "try": 3, + } - details = log["giveup"][0] + details = appender.events["giveup"][0] assert details == { "args": (1, 2, 3), "kwargs": {"foo": 1, "bar": 2}, @@ -353,31 +395,33 @@ async def foo_bar_baz(): @pytest.mark.asyncio -async def test_on_predicate_success() -> None: - log, log_success, log_backoff, log_giveup = _log_hdlrs() - +async def test_on_predicate_success(appender: EventAppender) -> None: @backoff.on_predicate( backoff.constant, - on_success=log_success, - on_backoff=log_backoff, - on_giveup=log_giveup, + on_backoff=appender.on_event("backoff"), + on_giveup=appender.on_event("giveup"), + on_success=appender.on_event("success"), + on_try=appender.on_event("try"), jitter=None, interval=0, ) @_save_target async def success(*args, **kwargs): # succeed after we've backed off twice - return len(log["backoff"]) == 2 + return len(appender.events["backoff"]) == 2 await success(1, 2, 3, foo=1, bar=2) # we try 3 times, backing off twice before succeeding - assert len(log["success"]) == 1 - assert len(log["backoff"]) == 2 - assert len(log["giveup"]) == 0 + assert appender.counts() == { + "backoff": 2, + "giveup": 0, + "success": 1, + "try": 3, + } for i in range(2): - details = log["backoff"][i] + details = appender.events["backoff"][i] assert details == { "args": (1, 2, 3), "kwargs": {"foo": 1, "bar": 2}, @@ -388,7 +432,7 @@ async def success(*args, **kwargs): "elapsed": IsFloat(gt=0), } - details = log["success"][0] + details = appender.events["success"][0] assert details == { "args": (1, 2, 3), "kwargs": {"foo": 1, "bar": 2}, @@ -400,14 +444,44 @@ async def success(*args, **kwargs): @pytest.mark.asyncio -async def test_on_predicate_giveup() -> None: - log, log_success, log_backoff, log_giveup = _log_hdlrs() +async def test_on_predicate_on_try_runs_before_attempt() -> None: + calls: list[object] = [] + + @backoff.on_predicate( + backoff.constant, + on_try=lambda details: calls.append(( + "try", + details["tries"], + details["elapsed"], + )), + jitter=None, + interval=0, + max_tries=3, + ) + async def falsey(): + calls.append("call") + return False + + await falsey() + + assert calls == [ + ("try", 1, 0), + "call", + ("try", 2, IsFloat(gt=0)), + "call", + ("try", 3, IsFloat(gt=0)), + "call", + ] + +@pytest.mark.asyncio +async def test_on_predicate_giveup(appender: EventAppender) -> None: @backoff.on_predicate( backoff.constant, - on_success=log_success, - on_backoff=log_backoff, - on_giveup=log_giveup, + on_success=appender.on_event("success"), + on_backoff=appender.on_event("backoff"), + on_giveup=appender.on_event("giveup"), + on_try=appender.on_event("try"), max_tries=3, jitter=None, interval=0, @@ -419,11 +493,14 @@ async def emptiness(*args, **kwargs): await emptiness(1, 2, 3, foo=1, bar=2) # we try 3 times, backing off twice and giving up once - assert len(log["success"]) == 0 - assert len(log["backoff"]) == 2 - assert len(log["giveup"]) == 1 + assert appender.counts() == { + "backoff": 2, + "giveup": 1, + "success": 0, + "try": 3, + } - details = log["giveup"][0] + details = appender.events["giveup"][0] assert details == { "args": (1, 2, 3), "kwargs": {"foo": 1, "bar": 2}, @@ -436,13 +513,14 @@ async def emptiness(*args, **kwargs): @pytest.mark.asyncio async def test_on_predicate_iterable_handlers() -> None: - hdlrs = [_log_hdlrs() for _ in range(3)] + appenders = [EventAppender() for _ in range(3)] @backoff.on_predicate( backoff.constant, - on_success=(h[1] for h in hdlrs), - on_backoff=(h[2] for h in hdlrs), - on_giveup=(h[3] for h in hdlrs), + on_backoff=(a.on_event("backoff") for a in appenders), + on_giveup=(a.on_event("giveup") for a in appenders), + on_success=(a.on_event("success") for a in appenders), + on_try=(a.on_event("try") for a in appenders), max_tries=3, jitter=None, interval=0, @@ -454,11 +532,14 @@ async def emptiness(*args, **kwargs): await emptiness(1, 2, 3, foo=1, bar=2) for i in range(3): - assert len(hdlrs[i][0]["success"]) == 0 - assert len(hdlrs[i][0]["backoff"]) == 2 - assert len(hdlrs[i][0]["giveup"]) == 1 + assert appenders[i].counts() == { + "backoff": 2, + "giveup": 1, + "success": 0, + "try": 3, + } - details = hdlrs[i][0]["giveup"][0] + details = appenders[i].events["giveup"][0] assert details == { "args": (1, 2, 3), "kwargs": {"foo": 1, "bar": 2}, @@ -479,6 +560,7 @@ async def test_on_predicate_constant_iterable(appender: EventAppender) -> None: on_backoff=appender.on_event("backoff"), on_giveup=appender.on_event("giveup"), on_success=appender.on_event("success"), + on_try=appender.on_event("try"), jitter=None, ) async def falsey(): @@ -489,6 +571,7 @@ async def falsey(): "backoff": len(waits), "giveup": 1, "success": 0, + "try": len(waits) + 1, } for i, wait in enumerate(waits): @@ -496,33 +579,35 @@ async def falsey(): @pytest.mark.asyncio -async def test_on_exception_jitter() -> None: - log, log_success, log_backoff, log_giveup = _log_hdlrs() - +async def test_on_exception_jitter(appender: EventAppender) -> None: @backoff.on_exception( backoff.constant, Exception, - on_success=log_success, - on_backoff=log_backoff, - on_giveup=log_giveup, + on_backoff=appender.on_event("backoff"), + on_giveup=appender.on_event("giveup"), + on_success=appender.on_event("success"), + on_try=appender.on_event("try"), jitter=lambda value: 0.0, interval=0, ) @_save_target async def succeeder(*args, **kwargs): # succeed after we've backed off twice - if len(log["backoff"]) < 2: + if len(appender.events["backoff"]) < 2: raise ValueError("catch me") await succeeder(1, 2, 3, foo=1, bar=2) # we try 3 times, backing off twice before succeeding - assert len(log["success"]) == 1 - assert len(log["backoff"]) == 2 - assert len(log["giveup"]) == 0 + assert appender.counts() == { + "backoff": 2, + "giveup": 0, + "success": 1, + "try": 3, + } for i in range(2): - details = log["backoff"][i] + details = appender.events["backoff"][i] assert details == { "args": (1, 2, 3), "kwargs": {"foo": 1, "bar": 2}, @@ -533,7 +618,7 @@ async def succeeder(*args, **kwargs): "exception": IsInstance(ValueError), } - details = log["success"][0] + details = appender.events["success"][0] assert details == { "args": (1, 2, 3), "kwargs": {"foo": 1, "bar": 2}, @@ -544,31 +629,33 @@ async def succeeder(*args, **kwargs): @pytest.mark.asyncio -async def test_on_predicate_jitter() -> None: - log, log_success, log_backoff, log_giveup = _log_hdlrs() - +async def test_on_predicate_jitter(appender: EventAppender) -> None: @backoff.on_predicate( backoff.constant, - on_success=log_success, - on_backoff=log_backoff, - on_giveup=log_giveup, + on_backoff=appender.on_event("backoff"), + on_giveup=appender.on_event("giveup"), + on_success=appender.on_event("success"), + on_try=appender.on_event("try"), jitter=lambda value: 0.0, interval=0, ) @_save_target async def success(*args, **kwargs): # succeed after we've backed off twice - return len(log["backoff"]) == 2 + return len(appender.events["backoff"]) == 2 await success(1, 2, 3, foo=1, bar=2) # we try 3 times, backing off twice before succeeding - assert len(log["success"]) == 1 - assert len(log["backoff"]) == 2 - assert len(log["giveup"]) == 0 + assert appender.counts() == { + "backoff": 2, + "giveup": 0, + "success": 1, + "try": 3, + } for i in range(2): - details = log["backoff"][i] + details = appender.events["backoff"][i] assert details == { "args": (1, 2, 3), "kwargs": {"foo": 1, "bar": 2}, @@ -579,7 +666,7 @@ async def success(*args, **kwargs): "elapsed": IsFloat(gt=0), } - details = log["success"][0] + details = appender.events["success"][0] assert details == { "args": (1, 2, 3), "kwargs": {"foo": 1, "bar": 2}, diff --git a/tests/test_retry_context.py b/tests/test_retry_context.py index 9907781..8b472ee 100644 --- a/tests/test_retry_context.py +++ b/tests/test_retry_context.py @@ -136,6 +136,42 @@ def test_retry_context_handlers() -> None: assert isinstance(backoffs[0].get("exception"), ValueError) +def test_retry_context_on_try_runs_before_attempt() -> None: + calls: list[object] = [] + tries: list[ContextDetails] = [] + + def on_try(details: ContextDetails) -> None: + tries.append(details) + calls.append("try") + + def run() -> None: + for attempt in backoff.retry_context( + ValueError, + backoff.constant, + interval=0, + max_tries=3, + jitter=None, + on_try=on_try, + ): + with attempt: + calls.append("call") + raise ValueError("always fails") + + with pytest.raises(ValueError, match="always fails"): + run() + + # on_try fires before the attempt body runs, for every attempt. + assert calls == ["try", "call", "try", "call", "try", "call"] + assert [t["tries"] for t in tries] == [1, 2, 3] + + # elapsed reflects time as of the *previous* attempt, so it's 0 for the + # first try and increases (but doesn't include the just-finished sleep) + # for subsequent ones. + assert tries[0]["elapsed"] == 0 + assert tries[1]["elapsed"] > 0 + assert tries[2]["elapsed"] > tries[1]["elapsed"] + + def test_retry_context_wait_gen_exhausted() -> None: calls = [] giveups: list[ContextDetails] = [] @@ -278,6 +314,39 @@ async def on_backoff(details) -> None: assert isinstance(backoffs[0]["exception"], ValueError) +@pytest.mark.asyncio +async def test_aretry_context_on_try_runs_before_attempt() -> None: + calls: list[object] = [] + tries: list[ContextDetails] = [] + + def on_try(details: ContextDetails) -> None: + tries.append(details) + calls.append("try") + + async def run() -> None: + async for attempt in backoff.aretry_context( + ValueError, + backoff.constant, + interval=0, + max_tries=3, + jitter=None, + on_try=on_try, + ): + with attempt: + calls.append("call") + raise ValueError("always fails") + + with pytest.raises(ValueError, match="always fails"): + await run() + + # on_try fires before the attempt body runs, for every attempt. + assert calls == ["try", "call", "try", "call", "try", "call"] + assert [t["tries"] for t in tries] == [1, 2, 3] + assert tries[0]["elapsed"] == 0 + assert tries[1]["elapsed"] > 0 + assert tries[2]["elapsed"] > tries[1]["elapsed"] + + @pytest.mark.asyncio async def test_aretry_context_async_giveup_no_raise() -> None: calls = []