From c76f5286d93fdbef9d7d4c1c653d12c8d4d4fee0 Mon Sep 17 00:00:00 2001 From: disqualifier Date: Fri, 16 Oct 2026 03:56:23 -0400 Subject: [PATCH] fix: only codes 10015/50027 mark a webhook dead; serialize cold creates T1-1: _is_dead_webhook treated every NotFound/401/404 as dead, so a 404 for another resource (10003 Unknown Channel from a deleted thread=) deleted the picked webhook, possibly a third party's. key on discord's error code only. T2-5: concurrent cold get_or_create calls each created a webhook (8 sends -> 8 webhooks, 7 orphaned against the cap). a cache miss now takes a lock and re-checks the cache before restore-or-create. Signed-off-by: disqualifier --- src/dpy_webhooks/dpy_webhooks.py | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/dpy_webhooks/dpy_webhooks.py b/src/dpy_webhooks/dpy_webhooks.py index 27799b2..ae04d81 100644 --- a/src/dpy_webhooks/dpy_webhooks.py +++ b/src/dpy_webhooks/dpy_webhooks.py @@ -60,6 +60,7 @@ through :meth:`clear` as usual. """ from __future__ import annotations +import asyncio import io import logging import random @@ -71,7 +72,6 @@ log = logging.getLogger(__name__) MAX_WEBHOOKS_PER_CHANNEL = 10 -_DEAD_WEBHOOK_STATUSES = (401, 404) _UNKNOWN_WEBHOOK_CODE = 10015 _INVALID_TOKEN_CODE = 50027 @@ -132,11 +132,8 @@ class InMemoryWebhookStore: def _is_dead_webhook(exc: discord.HTTPException) -> bool: """true when an HTTPException signals a webhook that no longer exists or has an invalid - token (the recover-and-recreate trigger)""" - if isinstance(exc, discord.NotFound): - return True - if exc.status in _DEAD_WEBHOOK_STATUSES: - return True + token (the recover-and-recreate trigger); keyed on discord's error code only - a bare + 404/401 can be another resource (e.g. 10003 Unknown Channel for a deleted thread=)""" return exc.code in (_UNKNOWN_WEBHOOK_CODE, _INVALID_TOKEN_CODE) @@ -203,6 +200,7 @@ class DPYWebhooks: self.default_name = default_name self._cache: dict[int, discord.Webhook] = {} self._rr: dict[int, dict] = {} + self._create_lock = asyncio.Lock() async def get_or_create( self, @@ -212,16 +210,19 @@ class DPYWebhooks: ) -> discord.Webhook: """return the channel's managed webhook, restoring from store or creating if absent; raises WebhookCapacityError at the cap unless evict_oldest, and propagates Forbidden - when Manage Webhooks is missing""" + when Manage Webhooks is missing; concurrent cold calls share one create""" cached = self._cache.get(channel.id) if cached is not None: return cached - restored = await self._restore_from_store(channel) - if restored is not None: - return restored - - return await self._create(channel, evict_oldest=evict_oldest) + async with self._create_lock: + cached = self._cache.get(channel.id) + if cached is not None: + return cached + restored = await self._restore_from_store(channel) + if restored is not None: + return restored + return await self._create(channel, evict_oldest=evict_oldest) async def get(self, channel: discord.TextChannel) -> "Optional[discord.Webhook]": """return the managed webhook if one exists (in-process cache then store), else None;