Python ne peut pas prendre une weakref sur un ValueError. La déduplication de Sentry l'a appris à ses dépens : 1024 Ko par tâche asyncio vivante.
La DedupeIntegration de sentry-python empêche qu'une même erreur soit rapportée deux fois en se souvenant de la dernière exception. Se souvenir d'une exception est exactement ce qu'il ne faut pas faire : une exception détient sa traceback, une traceback détient ses frames, un frame détient chaque local. Le SDK le savait et stockait un weakref.ref(exc). Les trois lignes sous le commentaire `# we can only weakref non builtin types` font la seule chose que la weakref existait pour empêcher : un `except TypeError:` attrape le refus de Python de prendre une weakref sur un builtin et retient silencieusement l'exception. Sous asyncio, chaque tâche garde sa propre copie dans un ContextVar, donc 200 sessions de longue durée épinglent 205 Mo, toutes atteignables après gc.collect(). Deux tentatives avant le correctif — une empreinte par valeur qui échoue aux propres tests du SDK, une empreinte basée sur id qui écrase 2000 erreurs distinctes en 2 clés — puis l'approche du jeton d'identité qui garde le comportement inchangé et fait tomber la rétention à 0,5 Mo.

Pourquoi asyncio fait mal
`_last_seen` est un `ContextVar`. Sous asyncio, chaque tâche obtient sa propre copie de contexte. Donc ce n'est pas une exception retenue par processus. C'est une exception retenue par tâche vivante, chacune traînant sa traceback et chaque local de frame atteignable depuis elle. Le rapporteur de l'issue #6094 voyait ça dans un crawler web asyncio où les frames détenaient des corps de réponse entre 500 Ko et 1 Mo.
Mon premier instinct a été d'appeler ça une fuite non bornée. J'ai construit un pool de workers, je l'ai lancé, et il est resté plat. Ça valait la peine de le découvrir avant de l'écrire quelque part. Un pool fixe ne grandit pas, parce que la prochaine erreur de chaque worker écrase la précédente. La rétention est bornée à (tâches vivantes × payload). L'histoire de croissance n'est pas les erreurs dans le temps, c'est les tâches dans le temps : une tâche par session, par abonnement, par connexion. Donc j'ai mesuré contre le nombre de tâches vivantes à la place.

Le correctif que j'ai raté d'abord
Les mainteneurs avaient déjà rejeté le correctif évident. Le rapporteur proposait de sauter la dédup pour les builtins, et Sentry a refusé, disant vouloir « une approche de fingerprinting plus robuste qui peut aussi être utilisée pour les exceptions builtin » à la place. Donc j'ai proposé une empreinte : type d'exception, message, et le frame d'origine de la traceback. Toutes primitives immuables, rien de retenu. Je l'ai posté sur l'issue. Puis la suite de tests existante m'a dit que j'avais tort, de deux façons différentes.

La tentative qui a précédé la mienne
Tardivement, je suis allé fouiller dans la liste des branches du fork et j'ai trouvé deux branches mainteneur abandonnées de septembre 2025 : `antonpirker/dedupe-integration-memory-usage` et `antonpirker/make-dedupe-integration-more-memory-efficient`. Aucune n'a été mergée. La première fait une empreinte sur `(type_module, type_name, id(exc_value))`. Ce `id()` est la partie intéressante. Une fois que vous cessez de retenir l'exception, ce qui est tout l'intérêt du changement, son adresse devient immédiatement réutilisable, et CPython réutilise les adresses agressivement.

Le correctif qui a été livré
Garder l'identité. Cesser de tenir l'objet pour l'exprimer. Attacher un petit `_DedupeToken` weakréférençable à l'exception (un par exception, vivant précisément aussi longtemps que l'exception), et prendre une weakref sur le jeton plutôt que sur l'exception. `last_seen is token` équivaut au précédent `last_seen is exc`, donc le comportement de déduplication est inchangé — ce qui compte étant donné la demande explicite du fil d'issue de ne pas changer le comportement avant la refonte du fingerprinting. Attacher un attribut privé `_sentry_*` sur un objet utilisateur pour porter une weakref suit un précédent existant dans ce SDK : `sentry_sdk/integrations/django/__init__.py` définit `_sentry_drf_request_backref = weakref.ref(...)` sur la requête.


Le propre Seer de Sentry sur le sujet
Puis j'ai lancé le propre Seer de Sentry dessus. Il a trouvé la cause racine exactement, y compris que les ContextVars sont par tâche sous asyncio, ce qui m'avait pris un tour de débogage à démêler moi-même. Il a lu la source du SDK pour le faire. Puis il a proposé de stocker le type et l'id de l'exception. Le même piège. Son correctif aurait supprimé chaque erreur distincte comme un doublon.

Un dernier détail qui mérite d'être nommé, parce que c'est la partie qui m'a convaincu que les PR de Seer ont besoin de la même revue qu'une PR humaine. Le plan en prose de Seer proposait l'empreinte type + id (qui écrase les erreurs distinctes comme montré plus haut). Son patch réellement généré faisait autre chose, et pire : il enveloppait le `weakref.ref(exc)` défaillant dans `except TypeError: pass`, écartant silencieusement chaque exception builtin de la déduplication entièrement. Dans le test de tuple d'identité que j'ai fait tourner sur les deux variantes, ce même motif de collision d'adresse aurait fait que 1999 sur 2000 (100,0 %) des erreurs distinctes dans une session soient mal dédupliquées. La prose qu'il vous montre pour revue n'est pas nécessairement le diff qu'il écrit.
Les tests que j'ai ajoutés
Cinq nouveaux tests dans `tests/integrations/dedupe/test_dedupe.py`, chacun écrit pour attraper une régression spécifique que les deux tentatives abandonnées auraient introduite. `test_dedupe_does_not_retain_builtin_exception` prend une weakref sur le jeton et vérifie que l'exception elle-même est collectable par gc après retour du frame. `test_dedupe_still_dedupes_builtin_exception` vérifie que le correctif ne casse pas le cas qui motive l'intégration au départ — le même objet exception attrapé et re-rapporté doit toujours dédupliquer. `test_dedupe_distinguishes_equal_builtin_exceptions` est la régression de l'empreinte par valeur : deux instances `ValueError('same')` levées depuis la même ligne ne doivent pas se dédupliquer l'une l'autre. `test_dedupe_leaves_unraised_exception_untouched` vérifie que l'attachement du jeton ne mute pas les exceptions que l'intégration ne voit jamais. `test_dedupe_survives_exotic_exception_dict` couvre le seul caprice de Python qui pourrait casser l'approche par jeton : une sous-classe d'exception avec `__dict__ = None`. Dans ce cas `_identity_token` retourne None et l'intégration décline silencieusement la déduplication au lieu de crasher — un chemin fail-safe que la revue de Gemini a signalé et pour lequel j'ai écrit un test.
Le deuxième regard de Gemini sur mon patch
Avant d'ouvrir la PR, j'ai passé le diff dans Gemini en mode revue adversariale : « trouve toutes les façons dont ça casse ». Il a suggéré six modes de défaillance. Deux étaient de vrais crashs auxquels je n'avais pas pensé (le cas `__dict__ = None` ci-dessus, et un où l'attachement du jeton pouvait provoquer une race dans un contexte threadé — j'ai résolu ça en rendant l'attachement idempotent). Un a été réfuté en lisant le propre précédent du SDK pour les attributs privés `_sentry_*` sur les objets utilisateur. Les trois restants (scrubbers PII enlevant le jeton, exceptions qui redéfinissent `__setattr__`, callbacks de weakref se déclenchant pendant l'arrêt) étaient tous des cas où l'intégration retombe sur ne-pas-dédupliquer, ce qui est fail-safe : le pire cas est un événement en double, pas un crash, pas une fuite mémoire, pas une suppression erronée. J'ai ajouté des tests pour les deux vrais et documenté le chemin fail-safe pour les trois autres. C'est le motif que je veux avec la revue AI : elle fait remonter des cas, je décide lesquels sont réels, j'écris le test.
sentry-python DedupeIntegration Retained 1024 KB per Live Asyncio Task Because Python Cannot Weakref a ValueError. Root-Caused, Fixed with a Weak-Referenceable Identity Token, Repro'd, Measured, and Prepared as an Upstream PR to Fix Issue #6094.
Voir le projet