From b20c5445b475790ff24d16aed1d0dc7ff570291f Mon Sep 17 00:00:00 2001 From: disqualifier Date: Fri, 3 Jul 2026 19:14:32 -0400 Subject: [PATCH] fix: self-heal retry rebuilds consumed File objects (no 0-byte re-upload) send() and send_any() retry with the exact same discord.File objects after healing a dead webhook, but discord.py closes every File on the first send's exit - a buffer-backed File's retry then uploads 0 bytes silently (the buffer sits at EOF and nothing calls reset()), and a path-backed File raises "I/O operation on closed file" instead. The healed retry in both methods now routes kwargs through _rebuild_files_in_kwargs(), which rebuilds a fresh discord.File per entry in file=/files= from its original source (reopens a path-backed File, rewinds a still-seekable buffer) before the retry send. A File that can't be safely rebuilt raises ValueError rather than silently sending an empty attachment. The first, non-retry send is untouched. Signed-off-by: disqualifier --- README.md | 9 +++-- pyproject.toml | 2 +- src/dpy_webhooks/dpy_webhooks.py | 58 ++++++++++++++++++++++++++++++-- 3 files changed, 63 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index cff12b8..18578b7 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ each time (wasteful, rate-limited, and it leaks toward the cap). ## Install ``` -dpy_webhooks @ git+ssh://git@git.rethinkstudios.io/rethink-public/dpy_webhooks.git@v0.1.1 +dpy_webhooks @ git+ssh://git@git.rethinkstudios.io/rethink-public/dpy_webhooks.git@v0.1.2 ``` ## Usage @@ -83,7 +83,12 @@ The module docstring (`help(dpy_webhooks)` / IDE hover) is the source of truth. **Self-heal.** If `send` hits a dead webhook (deleted server-side / invalid token), the lib clears the record, recreates the webhook, and retries the send **once**; a second failure -raises loud. +raises loud. If the retried send carries `file=`/`files=`, the lib rebuilds a fresh +`discord.File` for each one before retrying — discord.py closes every File's handle after +the first send, so resending the same object would upload 0 bytes (or raise, for a +path-backed File). A File that can't be safely rebuilt (a non-seekable, already-exhausted +in-memory buffer with no backing path) raises `ValueError` rather than silently sending an +empty attachment. **Fail-loud.** Nothing is swallowed to `None`. A channel already at 10 webhooks raises `WebhookCapacityError` (pass `evict_oldest=True` to reclaim the oldest instead). Missing diff --git a/pyproject.toml b/pyproject.toml index c6a932d..fb34d1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "dpy_webhooks" -version = "0.1.1" +version = "0.1.2" 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" dependencies = [ diff --git a/src/dpy_webhooks/dpy_webhooks.py b/src/dpy_webhooks/dpy_webhooks.py index f93383c..0ab6d4d 100644 --- a/src/dpy_webhooks/dpy_webhooks.py +++ b/src/dpy_webhooks/dpy_webhooks.py @@ -34,7 +34,11 @@ nothing is swallowed to ``None``. a full channel (10 webhooks) with ``evict_olde raises :class:`WebhookCapacityError`. missing Manage Webhooks perms propagate discord's own ``Forbidden`` unwrapped. a dead cached webhook is cleared + recreated; only a failed recreation raises. ``send`` self-heals a dead webhook exactly once (clear + recreate + retry), -then raises loud. store failures propagate — the store owns its own durability. +then raises loud. a retry carrying ``file=``/``files=`` rebuilds each ``discord.File`` from +its source first, since discord.py closes a File's handle after the first send attempt and +resending the same object would otherwise upload it as 0 bytes; a File that can't be safely +rebuilt raises ``ValueError`` instead. store failures propagate — the store owns its own +durability. discover / select / rotate -------------------------- @@ -54,6 +58,7 @@ through :meth:`clear` as usual. """ from __future__ import annotations +import io import logging import random from typing import Any, Optional, Protocol, runtime_checkable @@ -133,6 +138,53 @@ def _is_dead_webhook(exc: discord.HTTPException) -> bool: return exc.code in (_UNKNOWN_WEBHOOK_CODE, _INVALID_TOKEN_CODE) +def _rebuild_file(file: discord.File) -> discord.File: + """return a fresh, unconsumed discord.File built from an existing one's source + + discord.py closes every File's underlying handle when the request that sent it + exits (MultipartParameters.__exit__), so a File already used in one send() call + can't be handed to a second send() call - a path-backed File raises on reuse and + a buffer-backed File silently uploads 0 bytes (the buffer sits at EOF, and + File.reset() is only invoked by discord.py's own internal per-request retry, never + across two separate send() calls). a path-backed File (_owner=True) is reopened + from fp.name; a seekable in-memory buffer (_owner=False) is rewound and wrapped in + a fresh File so the original object's state is untouched. raises ValueError rather + than silently uploading 0 bytes when the source can't be safely rebuilt (a + non-seekable, already-exhausted buffer with no path to reopen from)""" + if file._owner: + source: Any = file.fp.name + elif isinstance(file.fp, io.IOBase) and file.fp.seekable() and not file.fp.closed: + file.fp.seek(file._original_pos) + source = file.fp + else: + raise ValueError( + f"dpy_webhooks: cannot rebuild File {file.filename!r} for a self-heal retry " + "(non-seekable or closed in-memory buffer with no backing path)" + ) + return discord.File( + source, + filename=file.filename, + spoiler=file.spoiler, + description=file.description, + ) + + +def _rebuild_files_in_kwargs(kwargs: "dict[str, Any]") -> "dict[str, Any]": + """return a shallow-copied kwargs dict with every discord.File in file=/files= + replaced by a freshly rebuilt one, ready for a retry send; kwargs carrying neither + key are returned unchanged (no copy needed)""" + if "file" not in kwargs and "files" not in kwargs: + return kwargs + rebuilt = dict(kwargs) + if "file" in rebuilt and isinstance(rebuilt["file"], discord.File): + rebuilt["file"] = _rebuild_file(rebuilt["file"]) + if "files" in rebuilt and rebuilt["files"] is not None: + rebuilt["files"] = [ + _rebuild_file(f) if isinstance(f, discord.File) else f for f in rebuilt["files"] + ] + return rebuilt + + class DPYWebhooks: """manage one reusable, persisted webhook per channel""" @@ -238,7 +290,7 @@ class DPYWebhooks: else: await self._delete_dead(webhook) webhook = await self.get_or_create(channel) - return await webhook.send(**kwargs) + return await webhook.send(**_rebuild_files_in_kwargs(kwargs)) async def _delete_dead(self, webhook: discord.Webhook) -> None: """delete a webhook confirmed dead server-side; already-gone is not an error""" @@ -274,7 +326,7 @@ class DPYWebhooks: await self.clear(channel) webhook = await self.get_or_create(channel) - return await webhook.send(**kwargs) + return await webhook.send(**_rebuild_files_in_kwargs(kwargs)) async def clear(self, channel: discord.TextChannel) -> None: """delete the managed webhook server-side (if known) and drop its store + cache