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 <dev@disqualifier.me>
This commit is contained in:
2026-07-03 19:14:32 -04:00
parent 611fea898b
commit b20c5445b4
3 changed files with 63 additions and 6 deletions
+7 -2
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@v0.1.1 dpy_webhooks @ git+ssh://git@git.rethinkstudios.io/rethink-public/dpy_webhooks.git@v0.1.2
``` ```
## Usage ## 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 **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 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 **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 `WebhookCapacityError` (pass `evict_oldest=True` to reclaim the oldest instead). Missing
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "dpy_webhooks" 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." 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 = [
+55 -3
View File
@@ -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 raises :class:`WebhookCapacityError`. missing Manage Webhooks perms propagate discord's own
``Forbidden`` unwrapped. a dead cached webhook is cleared + recreated; only a failed ``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), 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 discover / select / rotate
-------------------------- --------------------------
@@ -54,6 +58,7 @@ through :meth:`clear` as usual.
""" """
from __future__ import annotations from __future__ import annotations
import io
import logging import logging
import random import random
from typing import Any, Optional, Protocol, runtime_checkable 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) 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: class DPYWebhooks:
"""manage one reusable, persisted webhook per channel""" """manage one reusable, persisted webhook per channel"""
@@ -238,7 +290,7 @@ class DPYWebhooks:
else: else:
await self._delete_dead(webhook) await self._delete_dead(webhook)
webhook = await self.get_or_create(channel) 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: async def _delete_dead(self, webhook: discord.Webhook) -> None:
"""delete a webhook confirmed dead server-side; already-gone is not an error""" """delete a webhook confirmed dead server-side; already-gone is not an error"""
@@ -274,7 +326,7 @@ class DPYWebhooks:
await self.clear(channel) await self.clear(channel)
webhook = await self.get_or_create(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: async def clear(self, channel: discord.TextChannel) -> None:
"""delete the managed webhook server-side (if known) and drop its store + cache """delete the managed webhook server-side (if known) and drop its store + cache