Async webhook sender — round-robin URLs, retry, proxy rotation; optional discord.py embeds
Go to file
disqualifier ef20bc51f0 fix: never-raises net widened to unexpected exceptions; changelog/429 docs (v0.1.4)
M-2: _attempt caught only (aiohttp.ClientError, asyncio.TimeoutError); an unexpected
error escaping the attempt (closed injected session -> RuntimeError, malformed proxy url
-> ValueError) propagated out of send(), breaking the documented 'never raises on a send
failure' contract. add an outer catch-all in _send_loop converting any such exception to a
falsy WebhookResult(ok=False), logged at warning with exc_info.

aiowebhooks-F3: README 429 section + changelog were stale vs the v0.1.3 'retry any 429'
fix; added the no-parseable-wait-still-retries wording and v0.1.3/v0.1.4 changelog entries.

verified by execution: closed-session (RuntimeError) and bad-proxy (ValueError) controls
both fire and now return ok=False instead of raising.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 20:47:30 -04:00
src/aiowebhooks fix: never-raises net widened to unexpected exceptions; changelog/429 docs (v0.1.4) 2026-06-29 20:47:30 -04:00
.gitignore init: async webhook sender (aiohttp) with retry & proxy rotation, optional discord embeds 2026-06-25 14:50:05 -04:00
pyproject.toml fix: never-raises net widened to unexpected exceptions; changelog/429 docs (v0.1.4) 2026-06-29 20:47:30 -04:00
README.md fix: never-raises net widened to unexpected exceptions; changelog/429 docs (v0.1.4) 2026-06-29 20:47:30 -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.4
# discord embeds / identity helpers need the extra:
aiowebhooks[discord] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiowebhooks.git@v0.1.4

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.4 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.
  • 5xx — retried with exponential backoff, capped by max_retries.
  • 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 just fails after normal retry.

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