Files
aiowebhooks/README.md
T
dsql 6d4183948e fix: no sleep on exhausted final 429 attempt; correct errors.py doc (v0.1.6)
A 429 retry_after was slept immediately, including on the final attempt
that goes on to exhaust max_retries — a pointless wait right before
giving up. The wait is now carried in a per-call mutable cell and slept
at the start of the next attempt instead, so it's honored before every
attempt that actually runs and never after the last one.

errors.py's docstring claimed the exported error types cover the
missing-[discord]-extra raise path; that raise is a plain RuntimeError,
not one of these types. Reworded to match.

Compressed essay-length docstrings/comments across sender.py; no
behavior change. Verified against the aioproxies twin: aiowebhooks'
proxy-key normalization already routes zero-padded ports through
urlsplit().port (parses to int, no zero-pad on render), so host:080
and host:80 already collapse to one canonical key — no code change
needed there.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-02 23:23:24 -04:00

210 lines
9.3 KiB
Markdown

# 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.6
# discord embeds / identity helpers need the extra:
aiowebhooks[discord] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiowebhooks.git@v0.1.6
```
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.6` 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.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.