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>
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
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:
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:
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 (bodyretry_afterfirst — Discord sends seconds — then theRetry-Afterheader) 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 toMAX_RETRY_AFTER(300s) — a server-controlled wait can never stall asend()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
satisfies this, but it is not a dependency (never imported). The pairing is
failure-driven:
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])
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 checkresult.ok. - JSON-only: files/attachments,
tts, andallowed_mentionsare 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_afterno 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 fullretry_afterbefore 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.pyno longer implies the missing-[discord]-extra raise is one of the exported error types — it's a plainRuntimeError. - Docstrings/comments compressed; no behavior change.
v0.1.5
- 429
retry_afterbounded: a non-finite server-controlled wait (inf/nan, from the bodyretry_afteror theRetry-Afterheader) is now rejected outright, and a finite wait is clamped toMAX_RETRY_AFTER(300s). Previously a barefloat()parse slept the value verbatim, unbounded and outsidemax_retriesaccounting — an adversarial or ms-vs-s-misconfigured server could stall asend()for hours. - Connection/timeout errors now retry without a proxy provider: previously
aiohttp.ClientError/asyncio.TimeoutErrorwith noproxies=set failed one-shot, contradicting both the README and the single most retry-worthy failure class. Now it retries undercommons.aretry's backoff, capped bymax_retries, same as a 5xx — and still returnsok=False(never raises) once retries are exhausted. Proxy-rotation behavior (burn + rotate, capped bymax_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 falsyWebhookResult(ok=False, ...)instead of propagating out ofsend(), restoring the documented contract for those edge triggers.
v0.1.3
- 429 always retries: every
429is now retryable under aretry's backoff + cap, not only those with a parseableretry_after. A 429 with no bodyretry_afterand noRetry-Afterheader (edge/Cloudflare/generic webhook) previously failed one-shot.
v0.1.2
- Removed a dead
clockconstructor param (it was stored but never used). Pinnedcommonsto 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()raisingValueErrorfor an unknown proxy) used to escapesend(). 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 acommonsdependency.
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.