From 3f164abf84a5074f8d51e26d8921b5525f10a735 Mon Sep 17 00:00:00 2001 From: disqualifier Date: Thu, 25 Jun 2026 14:50:05 -0400 Subject: [PATCH] init: async webhook sender (aiohttp) with retry & proxy rotation, optional discord embeds Signed-off-by: disqualifier --- .gitignore | 16 +++++++ README.md | 135 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 .gitignore create mode 100644 README.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a6ff830 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# claude +CLAUDE.md + +# python +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ +.eggs/ + +# env +.venv/ +venv/ +.env +.pytest_cache/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..505691d --- /dev/null +++ b/README.md @@ -0,0 +1,135 @@ +# 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.0 +# discord embeds / identity helpers need the extra: +aiowebhooks[discord] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiowebhooks.git@v0.1.0 +``` + +The base pulls `aiohttp`. 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. + +## 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 + +- **429** — waits the `retry_after` from the body first (Discord sends seconds), then + the `Retry-After` header, then retries; capped by `max_retries`. +- **5xx** — retried, 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. + +## 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`. A provider `ProxiesExhaustedError` (or hitting the cap) returns a +failed result — never an infinite loop. 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`. +- v0.1.0 is JSON-only: **files/attachments, `tts`, and `allowed_mentions` are out** + (deliberate scope cut, addable later). The Discord surface is content + embeds + + identity. +- v0.1.0 rotation is round-robin only; try-next-on-failure across URLs is a later + feature. + +## Versioning + +Tagged `vX.Y.Z`. Pin the tag.