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>
210 lines
9.3 KiB
Markdown
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.
|