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

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 (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 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 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.

S
Description
Async webhook sender — round-robin URLs, retry, proxy rotation; optional discord.py embeds
Readme
165 KiB
Languages
Python 100%