A server-controlled 429 retry_after/Retry-After was slept verbatim with no finiteness check or ceiling, so an inf or huge value (adversarial or a ms-vs-s unit mismatch) could stall send() for hours outside max_retries accounting. Non-finite values are now rejected and finite values clamped to MAX_RETRY_AFTER (300s). Connection/timeout errors with no proxy provider set failed one-shot, contradicting the README's documented "normal retry" behavior and skipping the single most retry-worthy failure class. They now raise the internal _Retryable signal so commons.aretry retries them under max_retries, same as a 5xx, while still returning a failed WebhookResult (never raising) once retries are exhausted. 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.5
# discord embeds / identity helpers need the extra:
aiowebhooks[discord] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiowebhooks.git@v0.1.5
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.5 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.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.