# 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 # discord embeds / identity helpers need the extra: aiowebhooks[discord] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiowebhooks.git ``` 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`. ## 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** — honors the `retry_after` from the body first (Discord sends seconds), then the `Retry-After` header, sleeping that exact value; capped by `max_retries`. - **5xx** — retried with exponential backoff, capped by `max_retries`. - **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 just fails after normal retry. ## 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.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 is unpinned and tracks the latest on the default branch; append `@vX.Y.Z` to pin a specific release for reproducible installs.