Description
The GC data sampler's record_gc callback causes a RecursionError on Python 3.13 due to re-entrant garbage collection triggered inside platform.python_implementation().
Environment
- Python: 3.13 (CPython)
- New Relic Agent: 10.x+ (also reproducible on latest
main)
- OS: Linux (Docker, Debian-based)
- Framework: FastAPI + uvicorn
Stack Trace
File "newrelic/samplers/gc_data.py", line 52, in record_gc
if not self.enabled:
File "newrelic/samplers/gc_data.py", line 38, in enabled
if platform.python_implementation() == "PyPy" or not settings:
File "platform.py", line 1242, in python_implementation
return _sys_version()[0]
RecursionError: maximum recursion depth exceeded
Root Cause
_GCDataSource.start() unconditionally registers record_gc as a gc.callbacks entry.
- On every GC cycle,
record_gc() is invoked and calls self.enabled.
- The
enabled property calls platform.python_implementation() → platform._sys_version() → re.match().
- Python 3.13's incremental garbage collector can trigger a GC cycle during the
re.match() call (due to object allocation), which re-enters record_gc → self.enabled → platform.python_implementation() → infinite recursion.
This did not occur on Python ≤3.12 because the GC was not incremental and did not fire during re.match() allocations.
Why gc_runtime_metrics.enabled = false does not help
Setting gc_runtime_metrics.enabled = false in newrelic.ini does not prevent the crash because:
start() registers the callback unconditionally — there is no config check before gc.callbacks.append(self.record_gc).
- The
enabled property calls platform.python_implementation() before checking the config value, so the recursion occurs before the setting is ever evaluated.
# current code on main — the platform call happens before the config check
@property
def enabled(self):
settings = global_settings()
if platform.python_implementation() == "PyPy" or not settings: # ← recursion here
return False
elif settings and hasattr(settings, "gc_runtime_metrics"):
return settings.gc_runtime_metrics.enabled # ← never reached
else:
return False
Suggested Fix
Cache platform.python_implementation() at module or class level (it never changes at runtime), and/or check the config setting in start() before registering the callback:
_PYTHON_IMPL = platform.python_implementation() # cached once at import time
class _GCDataSource:
@property
def enabled(self):
settings = global_settings()
if _PYTHON_IMPL == "PyPy" or not settings:
return False
elif hasattr(settings, "gc_runtime_metrics"):
return settings.gc_runtime_metrics.enabled
else:
return False
def start(self):
if self.enabled and hasattr(gc, "callbacks"):
gc.callbacks.append(self.record_gc)
Current Workaround
Deregistering the callback after newrelic.agent.initialize():
import gc
gc.callbacks[:] = [
cb for cb in gc.callbacks
if not (hasattr(cb, "__self__") and type(cb.__self__).__name__ == "_GCDataSource")
]
Description
The GC data sampler's
record_gccallback causes aRecursionErroron Python 3.13 due to re-entrant garbage collection triggered insideplatform.python_implementation().Environment
main)Stack Trace
Root Cause
_GCDataSource.start()unconditionally registersrecord_gcas agc.callbacksentry.record_gc()is invoked and callsself.enabled.enabledproperty callsplatform.python_implementation()→platform._sys_version()→re.match().re.match()call (due to object allocation), which re-entersrecord_gc→self.enabled→platform.python_implementation()→ infinite recursion.This did not occur on Python ≤3.12 because the GC was not incremental and did not fire during
re.match()allocations.Why
gc_runtime_metrics.enabled = falsedoes not helpSetting
gc_runtime_metrics.enabled = falseinnewrelic.inidoes not prevent the crash because:start()registers the callback unconditionally — there is no config check beforegc.callbacks.append(self.record_gc).enabledproperty callsplatform.python_implementation()before checking the config value, so the recursion occurs before the setting is ever evaluated.Suggested Fix
Cache
platform.python_implementation()at module or class level (it never changes at runtime), and/or check the config setting instart()before registering the callback:Current Workaround
Deregistering the callback after
newrelic.agent.initialize():