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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## 4.4.5 - 2026-08-03

- Fix `ThreadPoolExecutor` leak on the exception path when `attempt_timeout` is set (#36). The executor created per attempt was only shut down on the timeout path, leaking worker threads whenever the target raised an exception. `_retry_loop_sync` now wraps the executor in `try/finally` so `_executor.shutdown(wait=False)` runs on every code path (success, timeout, and exception).
- Fix handler `details` dict always containing empty `args` and `kwargs` (#73). The real call arguments were never threaded into `RetryState`/`RetryCallState`, so handlers reading `details["args"]`/`details["kwargs"]` (e.g. `details["kwargs"]["query_id"]`) got `()`/`{}` and raised `KeyError`. `args`/`kwargs` are now plumbed from every entry point (`on_exception`/`on_predicate` sync+async, sync/async generators, `Retrying.call`/`async_call`, `RetryingCaller`/`AsyncRetryingCaller`, `hedge`) into the retry loop, and `RetryState`/`RetryCallState` are constructed with them so every handler (`on_attempt`, `on_backoff`, `on_giveup`, `on_success`, `before`, `after`, `before_sleep`) receives the actual call arguments.
- Add regression tests for both issues: executor thread leak (decorator, functional API, `RetryingCaller`, success path) and handler `details` args/kwargs (sync+async, generators, `Retrying`/`RetryingCaller`/`AsyncRetryingCaller`, `on_backoff`/`on_giveup`/`on_success`/`on_attempt`/`before`/`after`/`before_sleep`), including the `KeyError` reproduction from the issue.

## 4.4.4 - 2026-07-25

- Fix `hedge()` functional API not accepting arguments for the target function (#43). Added `args` and `kw` keyword-only parameters to `hedge()`, `_hedge_sync()`, and `_hedge_async()`, forwarding them to the target via `_make_hedge_target`.
Expand Down
16 changes: 16 additions & 0 deletions backon/_decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,8 @@ async def wrapper(*args: P.args, **kwargs: P.kwargs):
lambda: _collect_async_gen(target(*args, **kwargs)),
wait_gen,
sleep=_sleep,
args=args,
kwargs=kwargs,
**_kw,
)
if collected is not None:
Expand All @@ -377,6 +379,8 @@ def wrapper(*args: P.args, **kwargs: P.kwargs):
lambda: list(target(*args, **kwargs)),
wait_gen,
sleep=_sleep,
args=args,
kwargs=kwargs,
**_kw,
)
if collected is not None:
Expand All @@ -402,6 +406,8 @@ async def wrapped():
wrapped,
wait_gen,
sleep=_sleep,
args=args,
kwargs=kwargs,
**_kw,
),
)
Expand All @@ -422,6 +428,8 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
lambda: target(*args, **kwargs),
wait_gen,
sleep=_sleep,
args=args,
kwargs=kwargs,
**_kw,
),
)
Expand Down Expand Up @@ -589,6 +597,8 @@ async def wrapper(*args: P.args, **kwargs: P.kwargs):
lambda: _collect_async_gen(target(*args, **kwargs)),
wait_gen,
sleep=_sleep,
args=args,
kwargs=kwargs,
**_kw,
)
if collected is not None:
Expand All @@ -611,6 +621,8 @@ def wrapper(*args: P.args, **kwargs: P.kwargs):
lambda: list(target(*args, **kwargs)),
wait_gen,
sleep=_sleep,
args=args,
kwargs=kwargs,
**_kw,
)
if collected is not None:
Expand All @@ -636,6 +648,8 @@ async def wrapped():
wrapped,
wait_gen,
sleep=_sleep,
args=args,
kwargs=kwargs,
**_kw,
),
)
Expand All @@ -656,6 +670,8 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
lambda: target(*args, **kwargs),
wait_gen,
sleep=_sleep,
args=args,
kwargs=kwargs,
**_kw,
),
)
Expand Down
4 changes: 4 additions & 0 deletions backon/_hedging.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ def _hedge_sync(
retry_error_callback=None,
raise_on_giveup=True,
wait_gen_kwargs=wait_gen_kwargs,
args=args,
kwargs=kw or {},
)
futures.add(fut)

Expand Down Expand Up @@ -197,6 +199,8 @@ async def run_hedge():
retry_error_callback=None,
raise_on_giveup=True,
wait_gen_kwargs=wait_gen_kwargs,
args=args,
kwargs=kw or {},
)

tasks = [asyncio.create_task(run_hedge()) for _ in range(max_hedge)]
Expand Down
8 changes: 8 additions & 0 deletions backon/_retry/_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ def _retry_sync(
_holder: dict | None = None,
rate_limit: RateLimiter | None = None,
attempt_timeout: float | None = None,
args: tuple = (),
kwargs: dict | None = None,
) -> Any:
if wait_gen_kwargs is None:
wait_gen_kwargs = {}
Expand Down Expand Up @@ -117,6 +119,8 @@ def _retry_sync(
_holder=_holder,
rate_limit=rate_limit,
attempt_timeout=attempt_timeout,
args=args,
kwargs=kwargs,
)


Expand Down Expand Up @@ -149,6 +153,8 @@ async def _retry_async(
_holder: dict | None = None,
rate_limit: RateLimiter | None = None,
attempt_timeout: float | None = None,
args: tuple = (),
kwargs: dict | None = None,
) -> Any:
if wait_gen_kwargs is None:
wait_gen_kwargs = {}
Expand Down Expand Up @@ -204,6 +210,8 @@ async def _retry_async(
_holder=_holder,
rate_limit=rate_limit,
attempt_timeout=attempt_timeout,
args=args,
kwargs=kwargs,
)


Expand Down
8 changes: 8 additions & 0 deletions backon/_retry/_classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,8 @@ def wrapped():
_holder=_holder,
rate_limit=self._rate_limit,
attempt_timeout=self._attempt_timeout,
args=args,
kwargs=kwargs,
)
finally:
self._state = _holder.get("state")
Expand Down Expand Up @@ -316,6 +318,8 @@ async def wrapped():
_holder=_holder,
rate_limit=self._rate_limit,
attempt_timeout=self._attempt_timeout,
args=args,
kwargs=kwargs,
)
finally:
self._state = _holder.get("state")
Expand Down Expand Up @@ -426,6 +430,8 @@ def __call__(self, target: Callable[..., Any], *args: Any, **kwargs: Any) -> Any
after=self._after,
rate_limit=self._rate_limit,
attempt_timeout=self._attempt_timeout,
args=args,
kwargs=kwargs,
)

def copy(self) -> RetryingCaller:
Expand Down Expand Up @@ -555,6 +561,8 @@ async def wrapped():
after=self._after,
rate_limit=self._rate_limit,
attempt_timeout=self._attempt_timeout,
args=args,
kwargs=kwargs,
)

def copy(self) -> AsyncRetryingCaller:
Expand Down
8 changes: 8 additions & 0 deletions backon/_retry/_fast.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,8 @@ def _retry_fast_sync_inner(
_holder=None,
rate_limit=None,
attempt_timeout=None,
args=(),
kwargs=None,
):
if not is_enabled():
return target()
Expand Down Expand Up @@ -346,6 +348,8 @@ def _retry_fast_sync_inner(
_holder=_holder,
rate_limit=rate_limit,
attempt_timeout=attempt_timeout,
args=args,
kwargs=kwargs,
)


Expand All @@ -372,6 +376,8 @@ async def _retry_fast_async_inner(
_holder=None,
rate_limit=None,
attempt_timeout=None,
args=(),
kwargs=None,
):
if not is_enabled():
return await target()
Expand Down Expand Up @@ -437,4 +443,6 @@ async def _retry_fast_async_inner(
_holder=_holder,
rate_limit=rate_limit,
attempt_timeout=attempt_timeout,
args=args,
kwargs=kwargs,
)
8 changes: 8 additions & 0 deletions backon/_retry/_inner.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ def _retry_sync_inner(
_holder=None,
rate_limit=None,
attempt_timeout=None,
args=(),
kwargs=None,
):
if not is_enabled():
return target()
Expand Down Expand Up @@ -64,6 +66,8 @@ def _retry_sync_inner(
_holder=_holder,
rate_limit=rate_limit,
attempt_timeout=attempt_timeout,
args=args,
kwargs=kwargs,
)


Expand All @@ -90,6 +94,8 @@ async def _retry_async_inner(
_holder=None,
rate_limit=None,
attempt_timeout=None,
args=(),
kwargs=None,
):
if not is_enabled():
return await target()
Expand Down Expand Up @@ -123,4 +129,6 @@ async def _retry_async_inner(
_holder=_holder,
rate_limit=rate_limit,
attempt_timeout=attempt_timeout,
args=args,
kwargs=kwargs,
)
29 changes: 19 additions & 10 deletions backon/_retry/_loops.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,15 @@ def _retry_loop_sync(
_holder=None,
rate_limit=None,
attempt_timeout=None,
args=(),
kwargs=None,
):
state = RetryState(target=target)
state = RetryState(target=target, args=args, kwargs=kwargs or {})
start_time = _now()
state.start_time = start_time
call_state = RetryCallState(fn=target, start_time=start_time)
call_state = RetryCallState(
fn=target, start_time=start_time, args=args, kwargs=kwargs or {}
)
if _holder is not None:
_holder["state"] = state
_holder["call_state"] = call_state
Expand All @@ -70,14 +74,15 @@ def _retry_loop_sync(
try:
if attempt_timeout is not None:
_executor = ThreadPoolExecutor(max_workers=1)
_fut = _executor.submit(target)
try:
ret = _fut.result(timeout=attempt_timeout)
except _FuturesTimeoutError:
_fut.cancel()
_fut = _executor.submit(target)
try:
ret = _fut.result(timeout=attempt_timeout)
except _FuturesTimeoutError:
_fut.cancel()
raise AttemptTimeoutError() from None
finally:
_executor.shutdown(wait=False)
raise AttemptTimeoutError() from None
_executor.shutdown(wait=False)
else:
ret = target()
except TryAgain:
Expand Down Expand Up @@ -192,11 +197,15 @@ async def _retry_loop_async(
_holder=None,
rate_limit=None,
attempt_timeout=None,
args=(),
kwargs=None,
):
state = RetryState(target=target)
state = RetryState(target=target, args=args, kwargs=kwargs or {})
start_time = _now()
state.start_time = start_time
call_state = RetryCallState(fn=target, start_time=start_time)
call_state = RetryCallState(
fn=target, start_time=start_time, args=args, kwargs=kwargs or {}
)
if _holder is not None:
_holder["state"] = state
_holder["call_state"] = call_state
Expand Down
5 changes: 2 additions & 3 deletions backon/_typing.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
from __future__ import annotations

import logging
import sys
from collections.abc import Callable, Coroutine, Sequence
from typing import Any, TypedDict, TypeVar, Union

from backon._wait_gen import _Wait

if sys.version_info >= (3, 10):
try:
from typing import ParamSpec
else:
except ImportError: # pragma: no cover
Comment on lines +9 to +11

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove the new inline comment.

Line 11 adds # pragma: no cover. The backon/**/*.py rules require zero comments.

As per coding guidelines, “Use zero comments.”

Proposed change
-except ImportError:  # pragma: no cover
+except ImportError:
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try:
from typing import ParamSpec
else:
except ImportError: # pragma: no cover
try:
from typing import ParamSpec
except ImportError:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backon/_typing.py` around lines 9 - 11, Remove the inline “# pragma: no
cover” comment from the ImportError branch of the ParamSpec import fallback in
_typing.py, leaving the existing import and fallback behavior unchanged.

Source: Coding guidelines


class ParamSpec: # type: ignore[no-redef] # pragma: no cover
def __init__(self, name: str) -> None: ...
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "pdm.backend"

[project]
name = "backon"
version = "4.4.4"
version = "4.4.5"
description = "Function decoration for backoff and retry"
readme = "README.md"
license = "MIT"
Expand Down Expand Up @@ -81,7 +81,7 @@ testpaths = ["tests"]
asyncio_mode = "auto"

[tool.mypy]
python_version = "3.10"
python_version = "3.13"
strict = false
ignore_missing_imports = true
warn_unused_ignores = false
Expand Down
Loading