Compare commits

...
2 Commits
Author SHA1 Message Date
dsql 0e97d7f1ff chore: bump to 1.1.0
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-10-16 03:56:23 -04:00
dsql c76f5286d9 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 <dev@disqualifier.me>
2026-10-16 03:56:23 -04:00
3 changed files with 11 additions and 10 deletions

No files matched your search

+1 -1
View File
@@ -8,7 +8,7 @@ each time (wasteful, rate-limited, and it leaks toward the cap).
## Install ## Install
``` ```
dpy_webhooks @ git+ssh://git@git.rethinkstudios.io/rethink-public/dpy_webhooks.git@v1.0.0 dpy_webhooks @ git+ssh://git@git.rethinkstudios.io/rethink-public/dpy_webhooks.git@v1.1.0
``` ```
## Usage ## Usage
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "dpy_webhooks" name = "dpy_webhooks"
version = "1.0.0" version = "1.1.0"
description = "Per-channel Discord webhook management for discord.py — get-or-create, persist, enforce the cap, send. Config-free, injectable, installable." description = "Per-channel Discord webhook management for discord.py — get-or-create, persist, enforce the cap, send. Config-free, injectable, installable."
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
+9 -8
View File
@@ -60,6 +60,7 @@ through :meth:`clear` as usual.
""" """
from __future__ import annotations from __future__ import annotations
import asyncio
import io import io
import logging import logging
import random import random
@@ -71,7 +72,6 @@ log = logging.getLogger(__name__)
MAX_WEBHOOKS_PER_CHANNEL = 10 MAX_WEBHOOKS_PER_CHANNEL = 10
_DEAD_WEBHOOK_STATUSES = (401, 404)
_UNKNOWN_WEBHOOK_CODE = 10015 _UNKNOWN_WEBHOOK_CODE = 10015
_INVALID_TOKEN_CODE = 50027 _INVALID_TOKEN_CODE = 50027
@@ -132,11 +132,8 @@ class InMemoryWebhookStore:
def _is_dead_webhook(exc: discord.HTTPException) -> bool: def _is_dead_webhook(exc: discord.HTTPException) -> bool:
"""true when an HTTPException signals a webhook that no longer exists or has an invalid """true when an HTTPException signals a webhook that no longer exists or has an invalid
token (the recover-and-recreate trigger)""" token (the recover-and-recreate trigger); keyed on discord's error code only - a bare
if isinstance(exc, discord.NotFound): 404/401 can be another resource (e.g. 10003 Unknown Channel for a deleted thread=)"""
return True
if exc.status in _DEAD_WEBHOOK_STATUSES:
return True
return exc.code in (_UNKNOWN_WEBHOOK_CODE, _INVALID_TOKEN_CODE) return exc.code in (_UNKNOWN_WEBHOOK_CODE, _INVALID_TOKEN_CODE)
@@ -203,6 +200,7 @@ class DPYWebhooks:
self.default_name = default_name self.default_name = default_name
self._cache: dict[int, discord.Webhook] = {} self._cache: dict[int, discord.Webhook] = {}
self._rr: dict[int, dict] = {} self._rr: dict[int, dict] = {}
self._create_lock = asyncio.Lock()
async def get_or_create( async def get_or_create(
self, self,
@@ -212,15 +210,18 @@ class DPYWebhooks:
) -> discord.Webhook: ) -> discord.Webhook:
"""return the channel's managed webhook, restoring from store or creating if absent; """return the channel's managed webhook, restoring from store or creating if absent;
raises WebhookCapacityError at the cap unless evict_oldest, and propagates Forbidden 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) cached = self._cache.get(channel.id)
if cached is not None: if cached is not None:
return cached return cached
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) restored = await self._restore_from_store(channel)
if restored is not None: if restored is not None:
return restored return restored
return await self._create(channel, evict_oldest=evict_oldest) return await self._create(channel, evict_oldest=evict_oldest)
async def get(self, channel: discord.TextChannel) -> "Optional[discord.Webhook]": async def get(self, channel: discord.TextChannel) -> "Optional[discord.Webhook]":