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:
@@ -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
|
||||
|
||||
+1
-1
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user