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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
80 changes: 34 additions & 46 deletions backoff/_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from collections.abc import AsyncGenerator, Coroutine, Iterable

from backoff._typing import (
ContextDetails,
Details,
_BaseDetails,
_CallDetails,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
# <https://groups.google.com/forum/#!topic/python-tulip/yF9C-rFpiKk>
# <https://bugs.python.org/issue28613>
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]

Expand Down
10 changes: 5 additions & 5 deletions backoff/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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"])
Expand All @@ -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"])
Expand Down
74 changes: 36 additions & 38 deletions backoff/_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from collections.abc import Generator, Iterable

from backoff._typing import (
ContextDetails,
Details,
_BaseDetails,
_CallDetails,
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand All @@ -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

Expand Down