# aiowebhooks Async webhook sender over aiohttp. Give it a URL (or a round-robin pool) and a JSON payload; it POSTs it with 429/5xx retry and optional proxy rotation, and **always returns a `WebhookResult`** — it never raises on a send failure. A `[discord]` extra adds `DiscordWebhook` (username/avatar identity + `Embed` handling) layered over the same core. The base is generic (aiohttp only, no Discord knowledge). A Discord webhook is just a URL you POST JSON to, so the Discord layer only *builds* the payload and delegates the 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.7 # discord embeds / identity helpers need the extra: 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 only for `DiscordWebhook`. Drop the `@v0.1.7` suffix from the line above to install the latest unpinned. ## Core sender ```python from aiowebhooks import Webhook wh = Webhook("https://example.com/hook") # or a list of urls (round-robin) result = await wh.send({"content": "hello"}) # raw json dict if not result.ok: log.warning("webhook failed: %s (%s)", result.error, result.status) ``` Constructor: ```python Webhook( urls, # a single url or a list (cycled round-robin per send) *, session=None, # injected aiohttp.ClientSession; created+closed per call if None proxies=None, # duck-typed provider (see below) timeout=15, # per-request seconds max_retries=3, # 429 + 5xx retry cap max_proxy_retries=3, # proxy-rotation cap on timeout/connection errors ) ``` Inject a shared `session` for throughput (one session per process); without one, each send opens and closes its own. A single `Webhook` instance is safe to drive from many concurrent `send()` calls — each call tracks its own attempt count, so concurrent sends don't corrupt each other's `WebhookResult.attempts`. ## WebhookResult Every send returns this — branch on `ok`: ```python result.ok # bool result.status # final HTTP status, or None if the request never completed (timeout) result.url # which pool url was used result.attempts # total tries across retries / proxy rotations result.error # short cause string on failure, else None result.response # response payload: json dict if parseable, else text, else None result.proxy # canonical proxy string used (host:port:user:pass / host:port), or None ``` ## Retry & rate limits Status retries run through `commons.aretry` (exponential backoff + cap): - **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. A non-finite value (`inf`/`nan`) is rejected outright and a finite value is clamped to `MAX_RETRY_AFTER` (300s) — a server-controlled wait can never stall a `send()` past that ceiling, however large or malformed the value it sends. - **5xx** — retried with exponential backoff, capped by `max_retries`. - **connection/timeout errors** — retried with exponential backoff, capped by `max_retries`, same as a 5xx (with no proxy provider; see below for the proxy-rotation path). - **4xx** (other than 429) — fails immediately (no retry), returned as `ok=False`. Exceeding a cap returns a failed result rather than looping — and the result carries the **real** last status/body (not a synthetic placeholder). ## Proxy rotation (optional, duck-typed) Pass any provider exposing `.get()` (returns an aiohttp proxies dict) and `.burn(proxy)` — [`aioproxies`](https://git.rethinkstudios.io/rethink-public/aioproxies) satisfies this, but it is **not a dependency** (never imported). The pairing is failure-driven: ```python from aioproxies import AioProxies from aiowebhooks import Webhook pm = AioProxies(proxies=[...]) wh = Webhook(urls, proxies=pm, max_proxy_retries=3) result = await wh.send(payload) # sends through pm.get(); on a timeout/connection # error, pm.burn(proxy) and rotates to the next ``` On a timeout/connection error the current proxy is burned and the next is tried, up to `max_proxy_retries`. Hitting the cap, or **any exception from the provider's `get()`/`burn()`** (the provider is duck-typed and never imported, so its exception types can't be caught by class), returns a failed result — never an infinite loop, never an escape. With no provider, a timeout/connection error is retried directly under `max_retries` (the same cap and backoff a 5xx gets) instead of burn+rotate. ## Discord (`aiowebhooks[discord]`) ```python from aiowebhooks.discord import DiscordWebhook import discord dw = DiscordWebhook( "https://discord.com/api/webhooks/...", username="my-bot", avatar_url="https://.../avatar.png", proxies=pm, # same core options pass through ) embed = discord.Embed(title="deploy", description="shipped v0.1.0") result = await dw.send("done", embeds=[embed]) # discord.Embed or raw dict result = await dw.send("override", username="other-name") # per-send identity override ``` `embeds` accepts `discord.Embed` objects (via `.to_dict()`) and/or raw dicts. Per-send `username` / `avatar_url` override the manager identity. The build delegates to the core `Webhook`, so Discord sends inherit rotation / proxy / retry / `WebhookResult`. Without the extra installed, importing `aiowebhooks` still works; constructing or using `DiscordWebhook` raises `RuntimeError("discord support requires aiowebhooks[discord]")`. ## Notes - Every send returns a `WebhookResult`; the core never raises on a send failure and never prints. Callers check `result.ok`. - JSON-only: **files/attachments, `tts`, and `allowed_mentions` are out** (deliberate scope cut, addable later). The Discord surface is content + embeds + identity. - Rotation is round-robin only; try-next-on-failure across URLs is a later feature. ## 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 now carried to the START of the next attempt instead of slept immediately after seeing the 429. Previously the last (exhausted) attempt slept the full `retry_after` before giving up — a pointless wait since no retry followed. The wait is still honored (additive with aretry's backoff) before every attempt that actually runs. - Docs: `errors.py` no longer implies the missing-`[discord]`-extra raise is one of the exported error types — it's a plain `RuntimeError`. - Docstrings/comments compressed; no behavior change. ### v0.1.5 - **429 `retry_after` bounded:** a non-finite server-controlled wait (`inf`/`nan`, from the body `retry_after` or the `Retry-After` header) is now rejected outright, and a finite wait is clamped to `MAX_RETRY_AFTER` (300s). Previously a bare `float()` parse slept the value verbatim, unbounded and outside `max_retries` accounting — an adversarial or ms-vs-s-misconfigured server could stall a `send()` for hours. - **Connection/timeout errors now retry without a proxy provider:** previously `aiohttp.ClientError`/`asyncio.TimeoutError` with no `proxies=` set failed one-shot, contradicting both the README and the single most retry-worthy failure class. Now it retries under `commons.aretry`'s backoff, capped by `max_retries`, same as a 5xx — and still returns `ok=False` (never raises) once retries are exhausted. Proxy-rotation behavior (burn + rotate, capped by `max_proxy_retries`) is unchanged when a provider is set. ### 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 `commons` to v0.2.1. ### v0.1.1 - **Never-raises contract hardened:** an error from a duck-typed proxy provider's `get()`/`burn()` (e.g. `aioproxies.burn()` raising `ValueError` for an unknown proxy) used to escape `send()`. Now any provider exception is caught and converted to a failed result. - **Retry via `commons.aretry`:** 429/5xx retry moved onto the shared backoff engine — 5xx now backs off (was a tight no-backoff loop), and exhausted retries return the **real** last status/body instead of a synthetic placeholder. Adds a `commons` dependency. ## Versioning Releases are tagged `vX.Y.Z`. The install line above pins a release; drop the `@vX.Y.Z` suffix to install the latest unpinned. Pin deliberately for reproducible installs.