diff --git a/README.md b/README.md index cc6289e..1a372df 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.6 +aiowebhooks @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiowebhooks.git@v0.1.7 # discord embeds / identity helpers need the extra: -aiowebhooks[discord] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiowebhooks.git@v0.1.6 +aiowebhooks[discord] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiowebhooks.git@v0.1.7 ``` 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 +`aiowebhooks[discord]` adds `discord.py` (>=2.3, mainline, not discord.py-self), and only for `DiscordWebhook`. -Drop the `@v0.1.6` suffix from the line above to install the latest unpinned. +Drop the `@v0.1.7` suffix from the line above to install the latest unpinned. ## Core sender @@ -148,6 +148,13 @@ Without the extra installed, importing `aiowebhooks` still works; constructing o ## Changelog +### v0.1.7 + +- Docstrings/comments compressed (module docstrings and internal-method prose); no + behavior change. Public method contracts (`Webhook.send`, `WebhookResult` field + docs) are unchanged. +- Em-dash characters replaced with hyphens across the source. + ### v0.1.6 - **429 `retry_after` no longer sleeps on an exhausted final attempt:** the wait is diff --git a/pyproject.toml b/pyproject.toml index 7e03bca..f74c5c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "aiowebhooks" -version = "0.1.6" +version = "0.1.7" 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 4e8adf8..90c7176 100644 --- a/src/aiowebhooks/__init__.py +++ b/src/aiowebhooks/__init__.py @@ -1,18 +1,8 @@ -"""aiowebhooks — async webhook sender (aiohttp), optional discord.py embeds. +"""aiowebhooks - async webhook sender (aiohttp), optional discord.py embeds. -post a json payload to a webhook url (or a round-robin pool) with 429/5xx retry and -optional proxy rotation; every send returns a WebhookResult and never raises on a -send failure. the [discord] extra adds DiscordWebhook (username/avatar + Embed -handling) layered over the same core. - - from aiowebhooks import Webhook - - wh = Webhook("https://example.com/hook") - result = await wh.send({"content": "hello"}) - if not result.ok: - ... - -DiscordWebhook lives in aiowebhooks.discord and needs the [discord] extra. +post a json payload to a webhook url (or round-robin pool); every send returns a +WebhookResult and never raises on a send failure. see README for usage. the +[discord] extra adds DiscordWebhook (aiowebhooks.discord). """ from .errors import NoUrlsError, WebhookError @@ -21,4 +11,4 @@ from .sender import Webhook __all__ = ["Webhook", "WebhookResult", "WebhookError", "NoUrlsError"] -__version__ = "0.1.6" +__version__ = "0.1.7" diff --git a/src/aiowebhooks/discord.py b/src/aiowebhooks/discord.py index b64c0fa..3b283d3 100644 --- a/src/aiowebhooks/discord.py +++ b/src/aiowebhooks/discord.py @@ -2,9 +2,8 @@ `DiscordWebhook` wraps a core `Webhook`, adds discord identity (username/avatar, overridable per send) and `Embed` handling, builds the discord webhook json, and -delegates the POST to the core — inheriting rotation / proxy / retry / result. -importing this module without discord.py installed is fine; constructing or sending -raises a clear RuntimeError naming the extra. +delegates the POST to the core. importing this module without discord.py installed +is fine; constructing or sending raises a clear RuntimeError naming the extra. """ import logging @@ -25,7 +24,7 @@ _MISSING = "discord support requires aiowebhooks[discord]" class DiscordWebhook: - """discord webhook sender — builds payloads, delegates sending to a core Webhook""" + """discord webhook sender - builds payloads, delegates sending to a core Webhook""" def __init__( self, diff --git a/src/aiowebhooks/errors.py b/src/aiowebhooks/errors.py index eff36e0..7453dac 100644 --- a/src/aiowebhooks/errors.py +++ b/src/aiowebhooks/errors.py @@ -1,11 +1,8 @@ """exception types for aiowebhooks. -these are surfaced for callers that want to branch on a specific failure cause. -note the core `Webhook.send` does NOT raise on a send failure — it returns a -`WebhookResult` with `ok=False` and the cause captured in `error`. these types -cover bad construction (e.g. `NoUrlsError`) and serve as a base for any future -raising surface; the missing-`[discord]`-extra path raises a plain `RuntimeError`, -not one of these. +`Webhook.send` never raises on a send failure (returns `WebhookResult(ok=False)` +instead); these cover bad construction. the missing-`[discord]`-extra path raises a +plain `RuntimeError`, not one of these. """ diff --git a/src/aiowebhooks/result.py b/src/aiowebhooks/result.py index 92cca75..7036568 100644 --- a/src/aiowebhooks/result.py +++ b/src/aiowebhooks/result.py @@ -1,9 +1,4 @@ -"""the result object every send returns. - -`Webhook.send` never raises on a send failure; it always returns a `WebhookResult`. -callers branch on `result.ok`. success and every failure mode (4xx/5xx, timeout, -exhausted proxies) populate the same shape so call sites stay uniform. -""" +"""the result object every send returns; `Webhook.send` never raises, callers branch on `ok`.""" from dataclasses import dataclass from typing import Dict, Optional, Union diff --git a/src/aiowebhooks/sender.py b/src/aiowebhooks/sender.py index da1f5b2..9ae71bb 100644 --- a/src/aiowebhooks/sender.py +++ b/src/aiowebhooks/sender.py @@ -1,15 +1,9 @@ """core async webhook sender (aiohttp only, no discord knowledge). `Webhook` posts a JSON dict to a url (or round-robins a pool), handling 429/5xx -retries, connection/timeout retries, and optional proxy rotation, and always -returns a `WebhookResult` — it never raises on a send failure. the discord layer -builds payloads and delegates the actual POST here so it inherits rotation / proxy -/ retry / result. - -a 429's server-controlled `retry_after` wait is bounded: non-finite values -(inf/nan) are rejected and finite values are clamped to `MAX_RETRY_AFTER`, so an -adversarial or misconfigured server can never stall a send() past a bounded -ceiling. +retries, connection/timeout retries, and optional proxy rotation; always returns a +`WebhookResult`, never raises on a send failure. the discord layer delegates its +POST here so it inherits rotation/proxy/retry/result. """ import asyncio @@ -36,11 +30,7 @@ outright; finite values are clamped to this ceiling. class _Retryable(Exception): - """internal signal: a retryable HTTP status (429/5xx); carries the response - - raised inside an attempt so commons.aretry drives the backoff + cap; the loop - catches the final one to return the REAL last response, not a synthetic result. - """ + """internal signal for commons.aretry: a retryable 429/5xx; carries the real response""" def __init__(self, result: WebhookResult): super().__init__(f"retryable status {result.status}") @@ -152,15 +142,7 @@ class Webhook: async def _send_loop( self, session: aiohttp.ClientSession, url: str, payload: Dict ) -> WebhookResult: - """status-retry (via commons.aretry) wrapping proxy rotation; never raises - - commons.aretry owns the 429/5xx/no-provider-connection-error backoff schedule - + retry cap (max_retries), retrying on the internal _Retryable signal; on - exhaustion it re-raises the last _Retryable, whose carried result is the real - last response (not a synthetic status-0). proxy rotation on connection errors - lives inside the attempt, capped separately (max_proxy_retries); once that cap - is hit the failure is terminal, not retried again via aretry. - """ + """status-retry (commons.aretry, on _Retryable) wrapping proxy rotation; never raises""" counter = [0] pending_wait: List[Optional[float]] = [None] try: @@ -172,9 +154,7 @@ class Webhook: except _Retryable as exhausted: return exhausted.result except Exception as error: - # never-raises safety net: anything not aiohttp.ClientError/TimeoutError - # (closed injected session, malformed proxy url, ...) comes back as a - # failed result instead of escaping send() + # never-raises net: anything else (closed session, bad proxy url, ...) -> failed result 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, @@ -187,22 +167,10 @@ class Webhook: ) -> WebhookResult: """one logical send: proxy rotation + a single POST; may raise _Retryable - raises _Retryable (carrying the real response) on a 429/5xx so the caller's - aretry applies backoff. an explicit 429 retry_after is carried over and slept - at the START of the next attempt, never after the one that raises, so an - exhausted final attempt never sleeps a wait it won't use. a connection/timeout - error also raises _Retryable when no proxy provider is set (retries under - aretry like a 5xx); with a provider it instead drives burn+rotate up to - max_proxy_retries and only becomes terminal once that cap is hit. always - returns a final WebhookResult or raises _Retryable — never lets a provider - error escape. - - `counter`/`pending_wait` are per-call mutable cells ([0] / [None]) owned by - `_send_loop` and threaded through because aretry calls `_attempt` fresh on - every top-level retry, so a plain local wouldn't survive across calls. - `counter` tallies attempts; `pending_wait` carries a 429 retry_after to the - next attempt's sleep. keeps both local to one `send()`, safe for concurrent - sends on the same instance. + a 429 retry_after is carried to the START of the next attempt's sleep, never + slept after the attempt that raises. `counter`/`pending_wait` are per-call + mutable cells threaded from `_send_loop` (aretry calls this fresh each retry, + so a plain local wouldn't survive) - keeps state safe across concurrent sends. """ timeout = aiohttp.ClientTimeout(total=self.timeout) last_proxy: Optional[str] = None @@ -250,13 +218,8 @@ class Webhook: ) if status == 429: - # every 429 is retryable; an explicit retry_after is carried - # to the NEXT attempt's sleep (never slept here), so an - # exhausted final attempt never sleeps a wait it won't use. - # no parseable wait still retries under aretry's backoff+cap; - # an honored wait is additive with that backoff (over-waits, - # never under-waits). _retry_after rejects non-finite values - # and clamps finite ones to MAX_RETRY_AFTER. + # every 429 retries; retry_after (if any) is carried to the NEXT + # attempt's sleep, additive with aretry's backoff wait = self._retry_after(status, resp.headers, body) if wait is not None: pending_wait[0] = wait @@ -281,22 +244,14 @@ class Webhook: ok=False, status=None, url=url, attempts=attempts, error=f"{type(error).__name__}: {error}", proxy=last_proxy, ) - # no proxy provider: retry a connection/timeout error like a 5xx, - # under aretry's backoff + max_retries instead of failing one-shot; - # _send_loop's `except _Retryable` returns the carried result once - # exhausted, so never-raises still holds. + # no proxy provider: retry like a 5xx, under aretry's backoff + max_retries raise _Retryable(WebhookResult( ok=False, status=None, url=url, attempts=attempts, error=f"{type(error).__name__}: {error}", proxy=last_proxy, )) def _burn(self, proxy: Optional[str]) -> bool: - """burn the current proxy; return False if it can't be rotated - - the provider is duck-typed and never imported, so any exception from burn - (dead pool, unknown proxy, ...) is caught broadly and means "can't rotate" — - return False rather than let it escape send(). - """ + """burn the current proxy; return False (never raise) if it can't be rotated""" try: self._proxies.burn(proxy) return True