the v1.0.0 release still pinned pre-1.0.0 sibling tags, so a fresh install dragged in stale transitive deps. update the pin(s) to the current v1.0.x release and bump this lib to 1.0.1 so the corrected dependency chain ships under a new tag (v1.0.0 left intact). Signed-off-by: disqualifier <dev@disqualifier.me>
222 lines
9.7 KiB
Markdown
222 lines
9.7 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@v1.0.1
|
|
# discord embeds / identity helpers need the extra:
|
|
aiowebhooks[discord] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiowebhooks.git@v1.0.1
|
|
```
|
|
|
|
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 `@v1.0.1` 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.8
|
|
|
|
- Compressed 5 residual internal/trivial docstrings (`MAX_RETRY_AFTER`, `_Retryable`,
|
|
`_proxy_string`, `_retry_after`, `_attempt`) to one or two lines; no behavior change.
|
|
|
|
### 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.
|