diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f35cc0..55ea2a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,8 @@ - Move retry loop logic into a dedicated object [#189](https://github.com/python-backoff/backoff/pull/189) +- Use retry context internally inside our public decorators [#193]((https://github.com/python-backoff/backoff/pull/193) + ## [v2.3.1] - 2025-12-18 ### Fixed diff --git a/backoff/_async.py b/backoff/_async.py index 4f02ccd..006a898 100644 --- a/backoff/_async.py +++ b/backoff/_async.py @@ -12,6 +12,7 @@ from collections.abc import AsyncGenerator, Coroutine, Iterable from backoff._typing import ( + ContextDetails, Details, _BaseDetails, _CallDetails, @@ -156,6 +157,25 @@ async def retry(*args: P.args, **kwargs: P.kwargs) -> T: return retry # type: ignore[return-value] # ty:ignore[invalid-return-type] +def _adapt_context_handlers( + handlers: Iterable[_AsyncHandler], + target: Callable[..., Any], + args: tuple[Any, ...], + kwargs: dict[str, Any], +) -> list[_ContextHandler]: + async def adapted(details: ContextDetails) -> None: + full_details: Details = { + "target": target, + "args": args, + "kwargs": kwargs, + **details, + } + for hdlr in handlers: + await hdlr(full_details) + + return [adapted] + + def retry_exception( target: Callable[P, T], wait_gen: _WaitGenerator, @@ -185,57 +205,25 @@ async def retry( *args: P.args, **kwargs: P.kwargs, ) -> T: - state = _RetryState( + ret: T = None # type: ignore[assignment] # ty:ignore[invalid-assignment] + + async for attempt in aretry_context( + exception, wait_gen, - wait_gen_kwargs, max_tries=max_tries, max_time=max_time, - ) - while True: - state.start_attempt() - details: _BaseDetails = { - "target": target, - "args": args, - "kwargs": kwargs, - "tries": state.tries, - "elapsed": 0, - } - - try: + jitter=jitter, + giveup=giveup, + 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), + raise_on_giveup=raise_on_giveup, + wait_gen_kwargs=wait_gen_kwargs, + ): + with attempt: ret = await target(*args, **kwargs) # type: ignore[misc] # ty:ignore[invalid-await] - except exception as e: - details["elapsed"] = state.record_elapsed() - giveup_result = await giveup(e) - - if giveup_result or state.exhausted(): - await _call_handlers(on_giveup, **details, exception=e) - if raise_on_giveup: - raise - return None # type: ignore[return-value] # ty:ignore[invalid-return-type] - - try: - seconds = state.next_wait(e, jitter) - except StopIteration: - await _call_handlers(on_giveup, **details, exception=e) - raise e from None - - await _call_handlers(on_backoff, **details, wait=seconds, exception=e) - - # Note: there is no convenient way to pass explicit event - # loop to decorator, so here we assume that either default - # thread event loop is set and correct (it mostly is - # by default), or Python >= 3.5.3 or Python >= 3.6 is used - # where loop.get_event_loop() in coroutine guaranteed to - # return correct value. - # See for details: - # - # - await asyncio.sleep(seconds) - else: - details["elapsed"] = state.record_elapsed() - await _call_handlers(on_success, **details) - return ret + return ret return retry # type: ignore[return-value] # ty:ignore[invalid-return-type] diff --git a/backoff/_common.py b/backoff/_common.py index 8d172d0..f5a8348 100644 --- a/backoff/_common.py +++ b/backoff/_common.py @@ -2,13 +2,13 @@ import functools import logging -import sys import time import traceback import warnings from typing import TYPE_CHECKING, Any, Callable, TypeVar if TYPE_CHECKING: + import sys from collections.abc import Generator, Iterable from backoff._typing import ( @@ -233,9 +233,9 @@ def _log_backoff( msg = "Backing off %s(...) for %.1fs (%s)" log_args = [details["target"].__name__, details["wait"]] # ty:ignore[unresolved-attribute] - exc_typ, exc, _ = sys.exc_info() + exc = details.get("exception") if exc is not None: - exc_fmt = traceback.format_exception_only(exc_typ, exc)[-1] + exc_fmt = traceback.format_exception_only(type(exc), exc)[-1] log_args.append(exc_fmt.rstrip("\n")) else: log_args.append(details["value"]) @@ -251,9 +251,9 @@ def _log_giveup( msg = "Giving up %s(...) after %d tries (%s)" log_args = [details["target"].__name__, details["tries"]] # ty:ignore[unresolved-attribute] - exc_typ, exc, _ = sys.exc_info() + exc = details.get("exception") if exc is not None: - exc_fmt = traceback.format_exception_only(exc_typ, exc)[-1] + exc_fmt = traceback.format_exception_only(type(exc), exc)[-1] log_args.append(exc_fmt.rstrip("\n")) else: log_args.append(details["value"]) diff --git a/backoff/_sync.py b/backoff/_sync.py index 7d08a4f..6c39f9e 100644 --- a/backoff/_sync.py +++ b/backoff/_sync.py @@ -11,6 +11,7 @@ from collections.abc import Generator, Iterable from backoff._typing import ( + ContextDetails, Details, _BaseDetails, _CallDetails, @@ -99,7 +100,7 @@ def retry(*args: P.args, **kwargs: P.kwargs) -> T: try: seconds = state.next_wait(ret, jitter) except StopIteration: - _call_handlers(on_giveup, **details) + _call_handlers(on_giveup, **details, value=ret) break _call_handlers(on_backoff, **details, value=ret, wait=seconds) @@ -114,6 +115,25 @@ def retry(*args: P.args, **kwargs: P.kwargs) -> T: return retry +def _adapt_context_handlers( + handlers: Iterable[_Handler], + target: Callable[..., Any], + args: tuple[Any, ...], + kwargs: dict[str, Any], +) -> list[_ContextHandler]: + def adapted(details: ContextDetails) -> None: + full_details: Details = { + "target": target, + "args": args, + "kwargs": kwargs, + **details, + } + for hdlr in handlers: + hdlr(full_details) + + return [adapted] + + def retry_exception( target: Callable[P, T], wait_gen: _WaitGenerator, @@ -130,48 +150,26 @@ def retry_exception( wait_gen_kwargs: dict[str, Any], ) -> Callable[P, T]: @functools.wraps(target) - def retry(*args: P.args, **kwargs: P.kwargs) -> T: # type: ignore[return] # ty:ignore[invalid-return-type] - state = _RetryState( + def retry(*args: P.args, **kwargs: P.kwargs) -> T: + ret: T = None # type: ignore[assignment] # ty:ignore[invalid-assignment] + + for attempt in retry_context( + exception, wait_gen, - wait_gen_kwargs, max_tries=max_tries, max_time=max_time, - ) - while True: - state.start_attempt() - details: _BaseDetails = { - "target": target, - "args": args, - "kwargs": kwargs, - "tries": state.tries, - "elapsed": 0, - } - - try: + jitter=jitter, + giveup=giveup, # type: ignore[arg-type] # ty:ignore[invalid-argument-type] + 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), + raise_on_giveup=raise_on_giveup, + wait_gen_kwargs=wait_gen_kwargs, + ): + with attempt: ret = target(*args, **kwargs) - except exception as e: - details["elapsed"] = state.record_elapsed() - - if giveup(e) or state.exhausted(): - _call_handlers(on_giveup, **details, exception=e) - if raise_on_giveup: - raise - break - try: - seconds = state.next_wait(e, jitter) - except StopIteration: - _call_handlers(on_giveup, **details, exception=e) - raise e from None - - _call_handlers(on_backoff, **details, wait=seconds, exception=e) - - time.sleep(seconds) - else: - details["elapsed"] = state.record_elapsed() - _call_handlers(on_success, **details) - - return ret + return ret return retry