You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Memory leak: DedupeIntegration pins exception tracebacks in asyncio apps #6094
sentry_sdk.init with only LoggingIntegration + DedupeIntegration enabled.
In a long-lived asyncio task: build a local variable of ~1 MB so the frame carries that
much data, raise ValueError, log with logger.exception(...), keep the task running.
The exception, its traceback, and the 1 MB local stay alive for the lifetime of the task (and maybe after its death)
Expected Result
After logger.exception(...) returns and the except block exits, the exception, its
traceback, and all its frame locals become unreachable and memory are freed
Actual Result
The exception stays alive for the whole lifetime of the task. Memory grows with every new
captured exception on a new task, and the 1 MB local (plus the whole traceback chain)
stays pinned until the task itself ends.
RAM usage growth:
What I found
DedupeIntegration._last_seen is a ContextVar that stores the last captured exception.
The SDK tries to store it as weakref.ref(exc), but Python builtins (ValueError, TypeError, KeyError, ...) do not support weakref. On TypeError the SDK falls back to
a strong reference
Under asyncio every long-lived task keeps its own ContextVar state, so the strong
reference lives as long as the task. Through that reference the SDK pins:
the exception,
exc.__traceback__,
every frame local in the traceback.
In our web crawler frame locals held response payloads in the 500 KB-1 MB range, which is where
the RSS growth came from
Fix (what I did)
Replace the dedupe processor with one that stores weakref.ref(exc) when possible and
silently skips on TypeError. Dedupe is lost for builtins, leak is gone
Apologies for not providing an update on this sooner. We had taken a look at this, and we want to avoid introducing inconsistent behaviour in the SDK. So for now, we don't want to remove the deduping for built-ins just yet.
We'd like to move towards a more robust fingerprinting approach that can also be used for built-in exceptions. However, this is likely to lead to a significant change in behaviour on the SDK's part on what's deduplicated and what isn't and, as the team is currently focused on shipping streamed spans for the next major, we're unlikely to get to addressing this until after we've shipped the next major release of the SDK.
I'd like to take a run at this if it's still open.
I know the team decided against dropping dedupe for builtins, and that a more robust fingerprinting approach was the preferred direction. I think there's a version of that with a small blast radius that fixes the leak now without changing anything for non-builtins.
Keep weakref.ref(exc) as the primary path, so identity-based dedupe for non-builtin exceptions is unchanged. Replace only the except TypeError fallback, which is the line that leaks:
try:
integration._last_seen.set(weakref.ref(exc))
exceptTypeError:
integration._last_seen.set(exc) # strong ref -> pins exc.__traceback__ and every frame local
Instead of storing the exception, store a fingerprint derived from it: exception type, args, and the (filename, lineno, function) tuple for each traceback frame. That is all strings and ints, so nothing from the traceback stays reachable and the frame locals are freed as soon as the except block exits. Dedupe for builtins then compares fingerprints instead of identity.
The behavior change is confined to builtins, where the fallback already deviates from the weakref path: two separately raised but identical builtin exceptions from the same line would now dedupe where previously they would not.
If that direction sounds acceptable I'll put up a draft PR with tests and a before/after memory measurement on the reporter's repro. If you would rather hold this for the fingerprinting work in the next major, no problem, just let me know.
Correcting my own suggestion above: the fingerprint approach does not work, and the existing tests say why.
A value fingerprint cannot tell "the same exception object captured twice" apart from "the same error raised repeatedly from the same line". Two tests in tests/test_basics.py depend on that distinction, and both fail with a fingerprint:
test_breadcrumbs calls capture_exception(ValueError()) twice with two distinct, never-raised exceptions. No traceback and no message, so the fingerprints are identical and the second event is wrongly dropped.
test_option_before_breadcrumb calls do_this() three times, each raising a distinct ValueError("aha!") from the same line. Same type, same message, same origin frame, so events two and three are wrongly dropped.
That is the behaviour change you were worried about, so scratch that idea.
What does work is keeping identity semantics and only changing what holds them. Attach a small weak-referenceable token to the exception and weakly reference the token instead of the exception:
class_DedupeToken:
__slots__= ("__weakref__",)
There is one token per exception object and it lives exactly as long as the exception, so last_seen is token is equivalent to the old last_seen is exc, while the ContextVar no longer keeps the exception, its traceback, or any frame local alive. Exceptions that cannot carry a token fall back to the current behaviour.
Measured on the reported scenario, 25 long-lived asyncio tasks each pinning a 1 MB frame local:
payloads still reachable after gc
traced memory retained
before
25 / 25
25.5 MB
after
0 / 25
0.5 MB
Verification on Python 3.14: the existing dedupe tests pass unchanged, and running tests/test_basics.py, tests/test_client.py, tests/test_scope.py and tests/test_utils.py against the patch produces no new failures compared to the same run on an unpatched tree. ruff check, ruff format and mypy sentry_sdk are clean, with the one pre-existing pure_eval.py error present in both. I added three tests: one that fails without the fix because the frame local stays reachable, and two that pin the behaviour the fingerprint attempt broke.
Since this keeps dedupe behaviour identical, it should not need to wait for the fingerprinting rework. Happy to open a PR if you want it, or to leave it if you would rather fold it into that larger change.
Activity
linear-code commentedon Apr 19, 2026
PY-2381
ericapisani commentedon Apr 20, 2026
Hi @G000D1ESS ,
Thanks for opening an issue! We'll take a look at this as soon as possible.
G000D1ESS commentedon May 26, 2026
Hi, any updates?
ericapisani commentedon May 26, 2026
Hi @G000D1ESS ,
Apologies for not providing an update on this sooner. We had taken a look at this, and we want to avoid introducing inconsistent behaviour in the SDK. So for now, we don't want to remove the deduping for built-ins just yet.
We'd like to move towards a more robust fingerprinting approach that can also be used for built-in exceptions. However, this is likely to lead to a significant change in behaviour on the SDK's part on what's deduplicated and what isn't and, as the team is currently focused on shipping streamed spans for the next major, we're unlikely to get to addressing this until after we've shipped the next major release of the SDK.
1 remaining item
IgorGanapolsky commentedon Jul 24, 2026
JonathanSolvesProblems commentedon Aug 18, 2026
I'd like to take a run at this if it's still open.
I know the team decided against dropping dedupe for builtins, and that a more robust fingerprinting approach was the preferred direction. I think there's a version of that with a small blast radius that fixes the leak now without changing anything for non-builtins.
Keep
weakref.ref(exc)as the primary path, so identity-based dedupe for non-builtin exceptions is unchanged. Replace only theexcept TypeErrorfallback, which is the line that leaks:Instead of storing the exception, store a fingerprint derived from it: exception type,
args, and the(filename, lineno, function)tuple for each traceback frame. That is all strings and ints, so nothing from the traceback stays reachable and the frame locals are freed as soon as theexceptblock exits. Dedupe for builtins then compares fingerprints instead of identity.The behavior change is confined to builtins, where the fallback already deviates from the weakref path: two separately raised but identical builtin exceptions from the same line would now dedupe where previously they would not.
If that direction sounds acceptable I'll put up a draft PR with tests and a before/after memory measurement on the reporter's repro. If you would rather hold this for the fingerprinting work in the next major, no problem, just let me know.
JonathanSolvesProblems commentedon Aug 18, 2026
Correcting my own suggestion above: the fingerprint approach does not work, and the existing tests say why.
A value fingerprint cannot tell "the same exception object captured twice" apart from "the same error raised repeatedly from the same line". Two tests in
tests/test_basics.pydepend on that distinction, and both fail with a fingerprint:test_breadcrumbscallscapture_exception(ValueError())twice with two distinct, never-raised exceptions. No traceback and no message, so the fingerprints are identical and the second event is wrongly dropped.test_option_before_breadcrumbcallsdo_this()three times, each raising a distinctValueError("aha!")from the same line. Same type, same message, same origin frame, so events two and three are wrongly dropped.That is the behaviour change you were worried about, so scratch that idea.
What does work is keeping identity semantics and only changing what holds them. Attach a small weak-referenceable token to the exception and weakly reference the token instead of the exception:
There is one token per exception object and it lives exactly as long as the exception, so
last_seen is tokenis equivalent to the oldlast_seen is exc, while the ContextVar no longer keeps the exception, its traceback, or any frame local alive. Exceptions that cannot carry a token fall back to the current behaviour.Measured on the reported scenario, 25 long-lived asyncio tasks each pinning a 1 MB frame local:
Verification on Python 3.14: the existing dedupe tests pass unchanged, and running
tests/test_basics.py,tests/test_client.py,tests/test_scope.pyandtests/test_utils.pyagainst the patch produces no new failures compared to the same run on an unpatched tree.ruff check,ruff formatandmypy sentry_sdkare clean, with the one pre-existingpure_eval.pyerror present in both. I added three tests: one that fails without the fix because the frame local stays reachable, and two that pin the behaviour the fingerprint attempt broke.Branch: https://github.com/JonathanSolvesProblems/sentry-python/tree/fix/dedupe-builtin-exception-retention
Since this keeps dedupe behaviour identical, it should not need to wait for the fingerprinting rework. Happy to open a PR if you want it, or to leave it if you would rather fold it into that larger change.
fix(dedupe): Track seen exceptions via attribute instead of ContextVar (