Skip to content

fix: free the retrying object when a call returns - #758

Open
spicysharkco wants to merge 3 commits into
jd:mainfrom
spicysharkco:fix/iter-state-cycle
Open

spicysharkco wants to merge 3 commits into
jd:mainfrom
spicysharkco:fix/iter-state-cycle

Conversation

@spicysharkco

@spicysharkco spicysharkco commented Oct 15, 2026 •

Copy link
Copy Markdown

Every call of a @retry-decorated function leaves a reference cycle behind, even when the first attempt succeeds.

The decorator runs each call on a fresh copy() of the retrying object. BaseRetrying.iter() collects the actions of an attempt in iter_state.actions, and those actions are bound methods of the retrying object (self._run_retry, self._post_retry_check_actions, ...). The list is never emptied, so the copy refers to itself through its own thread-local state and only the cycle collector can free it. Arguments and return values are not kept alive by the cycle (a call with a 4 MB argument and a 4 MB result still holds ~1.7 KB), but the retrying object, its thread-local storage, the iteration state, and the bound methods and closures in the action list pile up until the next collection.

Unreachable objects per call, with gc disabled and one gc.collect() after 100 calls (CPython 3.10 / 3.14):

call before after
sync, first attempt succeeds 11 / 14 0 / 0
sync, one retry then success 11 / 14 0 / 0
Retrying()(fn) 13 / 17 0 / 0
for attempt in Retrying() 13 / 17 0 / 0
async, first attempt succeeds 19 / 22 0 / 0
async, one retry then success 19 / 22 0 / 0
tornado, first attempt succeeds 11 / 14 0 / 0

That is about 1.6 KB per sync call and 2.3 to 2.7 KB per async call held until the next collection, now under 20 bytes.

The fix clears the action list when iter() is done with it, in a finally so it also runs when an action raises (for example the RetryError that gives up). The list is only read inside iter() and is rebuilt by the next _begin_iter(); statistics, retry_state and the outcome are not touched. It clears the list that this iter() call iterated, not whatever iter_state.actions points to once the actions have run. Retrying, AsyncRetrying and TornadoRetrying all go through iter(), so the change is the same few lines in tenacity/__init__.py and tenacity/asyncio/__init__.py.

A call that gives up with a failed last attempt still leaves the usual cycle between the exception, its traceback and the frame that caught it. This change reduces that case but does not eliminate it. Unreachable objects per call, before and after (CPython 3.10 / 3.14):

call before after
sync, RetryError 50 / 53 45 / 48
sync, reraise=True 56 / 59 51 / 54
async, RetryError 75 / 78 58 / 61
async, reraise=True 81 / 84 64 / 67

async for attempt in AsyncRetrying() also still leaves a cycle: the controller keeps its RetryCallState in _retry_state, and the state's retry_object points back at the controller. The loop drops from 35 to 24 unreachable objects on 3.10 and from 38 to 27 on 3.14, not to zero. This PR covers the decorator, direct calls and the sync for attempt loop.

Open PR #757 changes the same lines of AsyncRetrying.iter(). Whichever of the two lands second needs a small rebase. If #757 lands first, the async hunk here becomes unnecessary, while the sync change, the tests and the release note still apply.

A decorated call that does not retry is not slower: minimum of 15 runs, collector enabled, sync 14.2 to 14.5 us before and 13.3 to 13.6 us after on 3.10, 8.7 to 8.8 us before and 7.8 to 7.9 us after on 3.14; async is within noise.

Tests: TestReferenceCycles (tests/test_tenacity.py) and TestAsyncReferenceCycles (tests/test_asyncio.py) run a decorated function, a controller called directly, and (sync) an attempt loop with gc disabled, and assert that gc.collect() finds nothing. They cover first-attempt success, retry-then-success, and a call where every attempt is retried and retry_error_callback returns a fallback, with and without reraise, and check that statistics still report the attempts. One test in each class uses a stop callable that raises, to cover the finally; it fails if the try/finally is removed and the clear() is kept after the loop. Another replaces iter_state.actions from a before callback, as a second call on a shared controller would, and checks that the iterated list is cleared and the replacement is left alone; it fails if the finally clears self.iter_state.actions instead. tests/test_tornado.py has a matching test for TornadoRetrying. All 18 fail on main and pass with the change. The suite passes on 3.10 (202 passed, 1 skipped) and 3.14 (203 passed, also with -W error), and ruff, strict mypy, reno lint and the Sphinx doctest and HTML builds pass. A release note is included.

Every call of a @retry-decorated function runs on a copy of the retrying
object, and BaseRetrying.iter() left that copy's iter_state.actions
populated with bound methods of the copy. The copy then referenced
itself through its own thread-local state, so only the cycle collector
could free it: 11 to 14 unreachable objects per sync call and 19 to 22
per async call (about 1.6 KB and 2.3 KB), even when the first attempt
succeeded.

Clear the action list once iter() is done with it, also when an action
raises. The list is only read inside iter() and is rebuilt on the next
call; statistics, retry_state and the outcome are not touched.
Retrying, AsyncRetrying and TornadoRetrying are all fixed through
iter().

A call that gives up with a failed last attempt still forms the usual
exception/traceback cycle through the frame that caught the exception.
That case is reduced, not eliminated: 50 to 45 unreachable objects per
sync call and 75 to 58 per async call on 3.10 (53 to 48 and 78 to 61 on
3.14).

"async for attempt in AsyncRetrying()" also still leaves a cycle, because
the retry state points back at the controller. It drops from 35 to 24
objects on 3.10 (38 to 27 on 3.14).
@mergify

mergify Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

@carlitogwapito

Copy link
Copy Markdown

One extra terminal path might be worth keeping in the tests: retry_error_callback returning a fallback, rather than an action raising.

I added a small test-only commit that checks the controller's weak reference directly, without relying on a whole-process garbage count. The fallback and attempt statistics are checked before dropping the last strong reference. It restores the collector's previous setting and skips non-CPython implementations.

On CPython 3.14.4, the test passes at a177afb; its base 32ac68f retains the controller until collection. I also checked both starting collector states and an ordinary successful-call control.

After taking the commit, the focused command is:

python -m pytest tests/test_fallback_controller_lifetime.py -q

I didn't run the full suite, docs/type checks, or pinned Ruff version; focused lint/format checks used Ruff 0.15.11. Feel free to use the test if it helps.

Add a case to the reference-cycle tests, sync and async, where every
attempt is retried and retry_error_callback returns a fallback value. The
call must return the fallback, report the attempt statistics, and leave no
garbage for the cycle collector.

Co-authored-by: carlitogwapito <20962552+carlitogwapito@users.noreply.github.com>
@spicysharkco

Copy link
Copy Markdown
Author

Thanks @carlitogwapito, good catch: the fallback path wasn't covered here. I added it to both stacks in 792d750, with you as co-author. It uses the classes' existing garbage-count check instead of a weakref, so the reference-cycle tests stay consistent. Both fail on 32ac68f and pass on this branch.

iter() binds the action list to a local before running it and clears
that list, not whatever iter_state.actions points to once the actions
have run. Add a test per class where a before callback replaces the
list mid-iteration, as another call on a shared controller would: the
iterated list must end up empty and the replacement must be left alone.
Clearing self.iter_state.actions instead fails both tests.
@spicysharkco

Copy link
Copy Markdown
Author

This is waiting for a review. It merges cleanly, CI passes, and the retry_error_callback fallback case raised above is covered in 792d750.

This branch has not been deployed

No deployments
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.

2 participants