From 70be3e5cce1e62c0c9ca5ea4c5f4177aaaf3ad79 Mon Sep 17 00:00:00 2001 From: Gyanu Date: Wed, 26 Aug 2026 09:11:50 +0530 Subject: [PATCH] Give each retry call its own live statistics dict. wraps() was reusing wrapped_f.statistics for every invocation so concurrent or reentrant calls cleared and mutated one dict. Keep a per-call dict during the run, then copy the result back onto the wrapper so functools.wraps still sees the latest stats. --- tenacity/__init__.py | 22 +++++++++++++--------- tenacity/asyncio/__init__.py | 22 +++++++++++++--------- tests/test_tenacity.py | 34 ++++++++++++++++++++++++++++++++-- 3 files changed, 58 insertions(+), 20 deletions(-) diff --git a/tenacity/__init__.py b/tenacity/__init__.py index e4660039..65851434 100644 --- a/tenacity/__init__.py +++ b/tenacity/__init__.py @@ -384,15 +384,19 @@ def wrapped_f(*args: t.Any, **kw: t.Any) -> t.Any: # Always create a copy to prevent overwriting the local contexts when # calling the same wrapped functions multiple times in the same stack copy = self.copy() - # Reuse the same statistics dict rather than rebinding the attribute - # so that the stats stay visible through additional decorators that - # copy attributes via functools.wraps (which copies the reference to - # this dict into the outer wrapper's __dict__). See issue #519. - stats = wrapped_f.statistics # type: ignore[attr-defined] - stats.clear() - copy._local.statistics = stats # noqa: SLF001 - self._local.statistics = stats - return copy(f, *args, **kw) + # Per-call stats so concurrent/reentrant invocations cannot clear + # each other's dict (#701). Publish into wrapped_f.statistics after + # the call so functools.wraps still sees the latest values (#519). + live: dict[str, t.Any] = {} + copy._local.statistics = live # noqa: SLF001 + self._local.statistics = live + try: + return copy(f, *args, **kw) + finally: + stats = wrapped_f.statistics # type: ignore[attr-defined] + stats.clear() + stats.update(live) + self._local.statistics = stats def retry_with(*args: t.Any, **kwargs: t.Any) -> "_RetryDecorated[P, R]": return self.copy(*args, **kwargs).wraps(f) diff --git a/tenacity/asyncio/__init__.py b/tenacity/asyncio/__init__.py index 3292a6ce..bba697b0 100644 --- a/tenacity/asyncio/__init__.py +++ b/tenacity/asyncio/__init__.py @@ -226,15 +226,19 @@ async def async_wrapped(*args: t.Any, **kwargs: t.Any) -> t.Any: # Always create a copy to prevent overwriting the local contexts when # calling the same wrapped functions multiple times in the same stack copy = self.copy() - # Reuse the same statistics dict rather than rebinding the attribute - # so that the stats stay visible through additional decorators that - # copy attributes via functools.wraps (which copies the reference to - # this dict into the outer wrapper's __dict__). See issue #519. - stats = async_wrapped.statistics # type: ignore[attr-defined] - stats.clear() - copy._local.statistics = stats # noqa: SLF001 - self._local.statistics = stats - return await copy(fn, *args, **kwargs) # type: ignore[type-var] + # Per-call stats so concurrent/reentrant invocations cannot clear + # each other's dict (#701). Publish into the wrapper dict after the + # call so functools.wraps still sees the latest values (#519). + live: dict[str, t.Any] = {} + copy._local.statistics = live # noqa: SLF001 + self._local.statistics = live + try: + return await copy(fn, *args, **kwargs) # type: ignore[type-var] + finally: + stats = async_wrapped.statistics # type: ignore[attr-defined] + stats.clear() + stats.update(live) + self._local.statistics = stats # Preserve attributes async_wrapped.retry = self # type: ignore[attr-defined] diff --git a/tests/test_tenacity.py b/tests/test_tenacity.py index 95ab8117..a01dabfc 100644 --- a/tests/test_tenacity.py +++ b/tests/test_tenacity.py @@ -18,6 +18,7 @@ import logging import pickle import re +import threading import time import typing import unittest @@ -1613,8 +1614,8 @@ def test_delay_since_first_attempt_available_on_first_attempt(self) -> None: retry=tenacity.retry_if_result(lambda x: x is None), ) def succeeds_first_try() -> bool: - assert "delay_since_first_attempt" in succeeds_first_try.statistics - assert succeeds_first_try.statistics["delay_since_first_attempt"] == 0 + assert "delay_since_first_attempt" in succeeds_first_try.retry.statistics + assert succeeds_first_try.retry.statistics["delay_since_first_attempt"] == 0 return True succeeds_first_try() @@ -1649,6 +1650,35 @@ def my_call() -> str: assert my_call.statistics["attempt_number"] == 1 assert my_call.statistics is my_call.__wrapped__.statistics + def test_concurrent_calls_do_not_share_live_statistics(self) -> None: + barrier = threading.Barrier(2) + seen: dict[str, list[int]] = {} + + @retry( + stop=tenacity.stop_after_attempt(3), + wait=tenacity.wait_none(), + retry=tenacity.retry_if_exception_type(ValueError), + reraise=True, + ) + def flaky(key: str) -> str: + barrier.wait() + seen.setdefault(key, []).append(flaky.retry.statistics["attempt_number"]) + if len(seen[key]) < 3: + raise ValueError("retry") + return key + + threads = [ + threading.Thread(target=flaky, args=("a",)), + threading.Thread(target=flaky, args=("b",)), + ] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + assert seen["a"] == [1, 2, 3] + assert seen["b"] == [1, 2, 3] + class TestEnabled: def test_enabled_false_skips_retry(self) -> None: