From ef20bc51f09969abd3707e3d2ef0c04785604639 Mon Sep 17 00:00:00 2001 From: disqualifier Date: Mon, 29 Jun 2026 20:47:30 -0400 Subject: [PATCH] fix: never-raises net widened to unexpected exceptions; changelog/429 docs (v0.1.4) M-2: _attempt caught only (aiohttp.ClientError, asyncio.TimeoutError); an unexpected error escaping the attempt (closed injected session -> RuntimeError, malformed proxy url -> ValueError) propagated out of send(), breaking the documented 'never raises on a send failure' contract. add an outer catch-all in _send_loop converting any such exception to a falsy WebhookResult(ok=False), logged at warning with exc_info. aiowebhooks-F3: README 429 section + changelog were stale vs the v0.1.3 'retry any 429' fix; added the no-parseable-wait-still-retries wording and v0.1.3/v0.1.4 changelog entries. verified by execution: closed-session (RuntimeError) and bad-proxy (ValueError) controls both fire and now return ok=False instead of raising. Signed-off-by: disqualifier --- README.md | 25 ++++++++++++++++++++----- pyproject.toml | 2 +- src/aiowebhooks/__init__.py | 2 +- src/aiowebhooks/sender.py | 10 ++++++++++ 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index bf7c192..3791de6 100644 --- a/README.md +++ b/README.md @@ -13,16 +13,16 @@ send to the core — inheriting rotation, proxy, retry, and result for free. ## Install ``` -aiowebhooks @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiowebhooks.git@v0.1.3 +aiowebhooks @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiowebhooks.git@v0.1.4 # discord embeds / identity helpers need the extra: -aiowebhooks[discord] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiowebhooks.git@v0.1.3 +aiowebhooks[discord] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiowebhooks.git@v0.1.4 ``` The base pulls `aiohttp` and `commons` (for the retry/backoff engine). Only `aiowebhooks[discord]` adds `discord.py` (>=2.3, mainline — not discord.py-self), and only for `DiscordWebhook`. -Drop the `@v0.1.3` suffix from the line above to install the latest unpinned. +Drop the `@v0.1.4` suffix from the line above to install the latest unpinned. ## Core sender @@ -72,8 +72,10 @@ result.proxy # canonical proxy string used (host:port:user:pass / host:port) Status retries run through `commons.aretry` (exponential backoff + cap): -- **429** — honors the `retry_after` from the body first (Discord sends seconds), then - the `Retry-After` header, sleeping that exact value; capped by `max_retries`. +- **429** — always retried, capped by `max_retries`. When a wait is parseable (body + `retry_after` first — Discord sends seconds — then the `Retry-After` header) it sleeps + that value before retrying; a 429 with no parseable wait (edge/Cloudflare/generic + webhook) still retries under aretry's backoff rather than failing one-shot. - **5xx** — retried with exponential backoff, capped by `max_retries`. - **4xx** (other than 429) — fails immediately (no retry), returned as `ok=False`. @@ -139,6 +141,19 @@ Without the extra installed, importing `aiowebhooks` still works; constructing o ## Changelog +### v0.1.4 + +- **Never-raises net widened:** an unexpected exception that escapes a send attempt (a + closed injected session → `RuntimeError`, a malformed proxy URL → `ValueError`) now + converts to a falsy `WebhookResult(ok=False, ...)` instead of propagating out of + `send()`, restoring the documented contract for those edge triggers. + +### v0.1.3 + +- **429 always retries:** every `429` is now retryable under aretry's backoff + cap, not + only those with a parseable `retry_after`. A 429 with no body `retry_after` and no + `Retry-After` header (edge/Cloudflare/generic webhook) previously failed one-shot. + ### v0.1.2 - Removed a dead `clock` constructor param (it was stored but never used). Pinned diff --git a/pyproject.toml b/pyproject.toml index ff1dedf..df689e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "aiowebhooks" -version = "0.1.3" +version = "0.1.4" description = "async webhook sender (aiohttp) with round-robin urls, retry, and proxy rotation; optional discord.py embeds" requires-python = ">=3.10" dependencies = [ diff --git a/src/aiowebhooks/__init__.py b/src/aiowebhooks/__init__.py index 623b77e..d0cdb0a 100644 --- a/src/aiowebhooks/__init__.py +++ b/src/aiowebhooks/__init__.py @@ -21,4 +21,4 @@ from .sender import Webhook __all__ = ["Webhook", "WebhookResult", "WebhookError", "NoUrlsError"] -__version__ = "0.1.3" +__version__ = "0.1.4" diff --git a/src/aiowebhooks/sender.py b/src/aiowebhooks/sender.py index fb3ebd6..f9e2706 100644 --- a/src/aiowebhooks/sender.py +++ b/src/aiowebhooks/sender.py @@ -145,6 +145,16 @@ class Webhook: ) except _Retryable as exhausted: return exhausted.result + except Exception as error: + # never-raises safety net: an unexpected error that escapes the attempt (a + # closed injected session -> RuntimeError, a malformed proxy url -> ValueError, + # anything not aiohttp.ClientError/TimeoutError) must come back as a failed + # result, not propagate out of send() + log.warning("webhook send failed unexpectedly on %s: %s", url, error, exc_info=True) + return WebhookResult( + ok=False, status=None, url=url, attempts=counter[0] or 1, + error=f"{type(error).__name__}: {error}", + ) async def _attempt( self, session: aiohttp.ClientSession, url: str, payload: Dict, counter: List[int]