Skip to content

RecursionError in GC sampler callback on Python 3.13 (re-entrant gc_data.record_gc) #1706

@arunp0

Description

@arunp0

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

  1. _GCDataSource.start() unconditionally registers record_gc as a gc.callbacks entry.
  2. On every GC cycle, record_gc() is invoked and calls self.enabled.
  3. The enabled property calls platform.python_implementation()platform._sys_version()re.match().
  4. 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_gcself.enabledplatform.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:

  1. start() registers the callback unconditionally — there is no config check before gc.callbacks.append(self.record_gc).
  2. 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")
]

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions