Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions backoff/_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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():
Expand Down Expand Up @@ -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)
Expand All @@ -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),
Expand Down Expand Up @@ -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],
Expand All @@ -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()

Expand Down
22 changes: 20 additions & 2 deletions backoff/_decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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.
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
11 changes: 9 additions & 2 deletions backoff/_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand All @@ -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():
Expand Down Expand Up @@ -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],
Expand All @@ -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),
Expand All @@ -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],
Expand All @@ -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()

Expand Down
2 changes: 1 addition & 1 deletion docs/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/user-guide/decorators.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down
20 changes: 20 additions & 0 deletions docs/user-guide/event-handlers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
51 changes: 27 additions & 24 deletions tests/common.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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()}
Loading
Loading