Skip to content

fix: infer caller name in before_log when used as context manager (#511) - #665

Open
Mukller wants to merge 2 commits into
jd:mainfrom
Mukller:fix/before-log-unknown-context-manager
Open

fix: infer caller name in before_log when used as context manager (#511)#665
Mukller wants to merge 2 commits into
jd:mainfrom
Mukller:fix/before-log-unknown-context-manager

Conversation

@Mukller

@Mukller Mukller commented Jul 30, 2026

Copy link
Copy Markdown

Summary

Fixes #511before_log() logs '<unknown>' when Retrying is used as a context manager without an explicit name= parameter.

Root cause

__iter__ creates RetryCallState(self, fn=None, ...) because there is no wrapped function in the context-manager path. get_fn_name() then falls back to str(self.retry_object) which returns "<unknown>" when no name= was supplied.

Fix

Two minimal changes in tenacity/__init__.py:

  1. BaseRetrying.__iter__ — after the first next() resumes the generator, sys._getframe(1) is the user's for-loop frame. We read co_qualname (Python ≥ 3.11) / co_name (older) and store it as retry_state._inferred_name when self._name is None.

  2. RetryCallState.get_fn_name — checks _inferred_name before returning str(self.retry_object), so the inferred name is used when available.

Behaviour

import logging
from tenacity import Retrying, RetryError, before_log

logger = logging.getLogger(__name__)

def my_function():
    for attempt in Retrying(before=before_log(logger, logging.DEBUG)):
        with attempt:
            raise Exception("hello")

# Before fix:  Starting call to '<unknown>', this is the 1st time calling it.
# After fix:   Starting call to 'my_function', this is the 1st time calling it.

Users who pass name="something" explicitly are completely unaffected.

Changes

File Change
tenacity/__init__.py Capture caller frame in __iter__; check _inferred_name in get_fn_name()
tests/test_tenacity.py Add test_logging_infers_caller_name regression test

Mukller added 2 commits July 29, 2026 23:21
)

When Retrying is used as a context manager without an explicit name=
parameter, retry_state.fn is None and get_fn_name() falls back to
str(self.retry_object) which returns '<unknown>'.

Fix: in __iter__, capture sys._getframe(1) — the for-loop frame in user
code — and store the qualified function name as retry_state._inferred_name.
get_fn_name() checks this attribute before returning '<unknown>', so
before_log() now logs the actual enclosing function instead of '<unknown>'.

Users who explicitly pass name='my_fn' are unaffected.
Python < 3.11 falls back from co_qualname to co_name gracefully.
… manager)

When Retrying is used as a context manager without an explicit name=, before_log
previously logged "<unknown>" because retry_state.fn is None in that path.
Add test_logging_infers_caller_name to verify that the enclosing function name
("my_retry_function") appears in the log message after the fix.

@Mukller Mukller left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Code review

Correctness of sys._getframe(1) inside a generator

The key question is: what does sys._getframe(1) return inside __iter__ (a generator)?

When for attempt in Retrying(...): executes:

  • Python creates the generator object by calling __iter__() — the body does not run yet.
  • The for loop calls next() on the generator, which resumes the body.
  • At that moment the call stack is: [0] __iter__ generator body → [1] the for-loop frame.

Frame 1 is therefore the function that contains the for attempt in ... loop — exactly what we want. This is consistent across CPython versions (the generator protocol has not changed in this regard).

co_qualname availability

co_qualname was added in Python 3.11 (PEP 667). The fix uses:

getattr(_frame.f_code, "co_qualname", None) or _frame.f_code.co_name

On Python < 3.11 this degrades gracefully to co_name (unqualified function name). The fix does not tighten the minimum Python version requirement.

No public API change

_inferred_name is set as an instance attribute with a leading underscore (internal). RetryCallState.__init__ signature is unchanged. Users who construct RetryCallState directly (as in the existing unit tests) receive no _inferred_name attribute, so getattr(self, "_inferred_name", None) returns None and behaviour is identical to before.

enabled=False path

The if not self.enabled: fast path in __iter__ is not touched — it skips before_log entirely (no self.before(retry_state) call), so it doesn't need _inferred_name.

Thread safety

_inferred_name is set once on the retry_state object, which is local to a single __iter__ call. No shared mutable state is introduced.

Suggested improvements (non-blocking)

  • Could also apply the same inference to AsyncRetrying.__aiter__ in tenacity/asyncio.py for parity.
  • The docstring for RetryCallState.get_fn_name now accurately describes all three cases (decorator, explicit name=, inferred frame).

Overall the change is minimal, backward-compatible, and targets the exact failure mode described in the issue.

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.

Unreachable code in before_log reached

1 participant