Skip to content

Keep statistics visible when @retry is further wrapped (fixes #519)#653

Open
apoorvdarshan wants to merge 2 commits into
jd:mainfrom
apoorvdarshan:fix/statistics-through-outer-decorator-519
Open

Keep statistics visible when @retry is further wrapped (fixes #519)#653
apoorvdarshan wants to merge 2 commits into
jd:mainfrom
apoorvdarshan:fix/statistics-through-outer-decorator-519

Conversation

@apoorvdarshan

Copy link
Copy Markdown

Summary

Fixes #519.

When a @retry-decorated function is further wrapped by another decorator that uses functools.wraps (for example a timing decorator), accessing func.statistics returned an empty {} instead of the real runtime statistics.

@timed()                              # any decorator using functools.wraps
@retry(stop=stop_after_attempt(3))
def my_call(...):
    ...

my_call()
my_call.statistics  # was {} — now populated

Cause

functools.wraps copies the inner wrapper's __dict__ (which includes the statistics reference) into the outer wrapper. The inner wrapped_f set wrapped_f.statistics = {} at decoration time, so the outer wrapper received a reference to that empty dict. On each call, wrapped_f.statistics was then rebound to a fresh per-call dict, while the outer wrapper kept pointing at the original, now-stale, empty dict — hence {}.

Fix

Reuse the same statistics dict in place on every call instead of rebinding the attribute: the per-call retry copy is pointed at the wrapper's stable dict (which is cleared at the start of the call and populated during it). Because the dict identity never changes, the statistics stay reachable through the outer wrapper's copied __dict__. The same change is applied to the async wrapper in tenacity/asyncio.

This only fixes the empty-statistics behaviour; the separate return_statistics_inline idea from the issue is intentionally out of scope.

Tests / Verification

  • Added test_statistics_visible_through_outer_decorator in both tests/test_tenacity.py and tests/test_asyncio.py. Both fail on main (KeyError: 'attempt_number') and pass with the fix. Each also asserts func.statistics is func.__wrapped__.statistics, i.e. the identity is now shared through the wrapper chain.
  • Full test suite green (pytest tests/), including the existing statistics/sync tests.
  • ruff check and ruff format --check clean; mypy (strict) clean on the changed modules.
  • Added a reno release note under releasenotes/notes/.

Disclosure: prepared with AI assistance; reviewed and verified locally.

When a @retry-decorated function was wrapped by another decorator that
uses functools.wraps (e.g. a timing decorator), accessing
``func.statistics`` returned an empty ``{}``.

functools.wraps copies the inner wrapper's ``__dict__`` (including the
``statistics`` reference) into the outer wrapper. On each call the inner
wrapper rebound ``wrapped_f.statistics`` to a fresh dict, so the outer
wrapper kept pointing at the original, now-stale, empty dict.

Reuse the same statistics dict in place on every call (clearing it and
sharing it with the per-call copy) instead of rebinding the attribute.
The dict identity stays stable, so the stats remain reachable through
the outer wrapper's copied ``__dict__``. The same change is applied to
the async wrapper.

Fixes jd#519.
The runtime fix keeps func.statistics visible when a @retry-decorated
function is further wrapped by another functools.wraps-based decorator.
However mypy (strict) rejected the tests because the public typing did
not expose the attributes the tests rely on:

  * _RetryDecorated had no __wrapped__ member, so accessing
    my_call.__wrapped__.statistics failed with [attr-defined].
  * the sync test's outer decorator was annotated
    Callable[..., Any] -> Callable[..., Any], erasing the
    _RetryDecorated type so .statistics/.__wrapped__ were unreachable.

Declare __wrapped__ on the _RetryDecorated protocol (it is always set by
functools.wraps on the retry wrapper) and make the sync test's outer
decorator type-preserving via a TypeVar, mirroring the async test. mypy,
ruff, pytest and the docs build are all green.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Statistics not set properly if retry fn is already wrapped

1 participant