2 Commits
8 changed files with 114 additions and 234 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
# claude
.claude/
CLAUDE.md
# python
__pycache__/
+18 -104
View File
@@ -13,16 +13,13 @@ 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.2
aiowebhooks @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiowebhooks.git@v0.1.0
# discord embeds / identity helpers need the extra:
aiowebhooks[discord] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiowebhooks.git@v1.0.2
aiowebhooks[discord] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiowebhooks.git@v0.1.0
```
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.2` suffix from the line above to install the latest unpinned.
The base pulls `aiohttp`. Only `aiowebhooks[discord]` adds `discord.py` (>=2.3,
mainline not discord.py-self), and only for `DiscordWebhook`.
## Core sender
@@ -50,9 +47,7 @@ Webhook(
```
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`.
send opens and closes its own.
## WebhookResult
@@ -70,23 +65,12 @@ result.proxy # canonical proxy string used (host:port:user:pass / host:port)
## 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).
- **429** — waits the `retry_after` from the body first (Discord sends seconds), then
the `Retry-After` header, then retries; capped by `max_retries`.
- **5xx** — retried, 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).
Exceeding a cap returns a failed result rather than looping.
## Proxy rotation (optional, duck-typed)
@@ -107,11 +91,9 @@ result = await wh.send(payload) # sends through pm.get(); on a timeout/connect
```
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.
`max_proxy_retries`. A provider `ProxiesExhaustedError` (or hitting the cap) returns a
failed result — never an infinite loop. With no provider, a timeout just fails after
normal retry.
## Discord (`aiowebhooks[discord]`)
@@ -142,80 +124,12 @@ Without the extra installed, importing `aiowebhooks` still works; constructing o
- 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.
- v0.1.0 is JSON-only: **files/attachments, `tts`, and `allowed_mentions` are out**
(deliberate scope cut, addable later). The Discord surface is content + embeds +
identity.
- v0.1.0 rotation is round-robin only; try-next-on-failure across URLs is a later
feature.
## 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.
Tagged `vX.Y.Z`. Pin the tag.
+1 -5
View File
@@ -4,12 +4,11 @@ build-backend = "hatchling.build"
[project]
name = "aiowebhooks"
version = "1.0.2"
version = "0.1.0"
description = "async webhook sender (aiohttp) with round-robin urls, retry, and proxy rotation; optional discord.py embeds"
requires-python = ">=3.10"
dependencies = [
"aiohttp>=3.9",
"commons @ git+https://git.rethinkstudios.io/rethink-public/commons.git@v1.0.0",
]
[project.optional-dependencies]
@@ -17,8 +16,5 @@ discord = [
"discord.py>=2.3",
]
[tool.hatch.metadata]
allow-direct-references = true
[tool.hatch.build.targets.wheel]
packages = ["src/aiowebhooks"]
+15 -10
View File
@@ -1,19 +1,24 @@
"""aiowebhooks - async webhook sender (aiohttp), optional discord.py embeds.
"""aiowebhooks async webhook sender (aiohttp), optional discord.py embeds.
post a json payload to a webhook url (or round-robin pool); every send returns a
WebhookResult and never raises on a send failure. see README for usage. the
[discord] extra adds DiscordWebhook (aiowebhooks.discord).
post a json payload to a webhook url (or a round-robin pool) with 429/5xx retry and
optional proxy rotation; every send returns a WebhookResult and never raises on a
send failure. the [discord] extra adds DiscordWebhook (username/avatar + Embed
handling) layered over the same core.
from aiowebhooks import Webhook
wh = Webhook("https://example.com/hook")
result = await wh.send({"content": "hello"})
if not result.ok:
...
DiscordWebhook lives in aiowebhooks.discord and needs the [discord] extra.
"""
from importlib.metadata import PackageNotFoundError, version
from .errors import NoUrlsError, WebhookError
from .result import WebhookResult
from .sender import Webhook
__all__ = ["Webhook", "WebhookResult", "WebhookError", "NoUrlsError"]
try:
__version__ = version("aiowebhooks")
except PackageNotFoundError:
__version__ = "0.0.0+unknown"
__version__ = "0.1.0"
+4 -3
View File
@@ -2,8 +2,9 @@
`DiscordWebhook` wraps a core `Webhook`, adds discord identity (username/avatar,
overridable per send) and `Embed` handling, builds the discord webhook json, and
delegates the POST to the core. importing this module without discord.py installed
is fine; constructing or sending raises a clear RuntimeError naming the extra.
delegates the POST to the core — inheriting rotation / proxy / retry / result.
importing this module without discord.py installed is fine; constructing or sending
raises a clear RuntimeError naming the extra.
"""
import logging
@@ -24,7 +25,7 @@ _MISSING = "discord support requires aiowebhooks[discord]"
class DiscordWebhook:
"""discord webhook sender - builds payloads, delegates sending to a core Webhook"""
"""discord webhook sender builds payloads, delegates sending to a core Webhook"""
def __init__(
self,
+5 -3
View File
@@ -1,8 +1,10 @@
"""exception types for aiowebhooks.
`Webhook.send` never raises on a send failure (returns `WebhookResult(ok=False)`
instead); these cover bad construction. the missing-`[discord]`-extra path raises a
plain `RuntimeError`, not one of these.
these are surfaced for callers that want to branch on a specific failure cause.
note the core `Webhook.send` does NOT raise on a send failure — it returns a
`WebhookResult` with `ok=False` and the cause captured in `error`. these types
exist for the few raise paths (bad construction, missing extra) and as a base for
any future raising surface.
"""
+6 -1
View File
@@ -1,4 +1,9 @@
"""the result object every send returns; `Webhook.send` never raises, callers branch on `ok`."""
"""the result object every send returns.
`Webhook.send` never raises on a send failure; it always returns a `WebhookResult`.
callers branch on `result.ok`. success and every failure mode (4xx/5xx, timeout,
exhausted proxies) populate the same shape so call sites stay uniform.
"""
from dataclasses import dataclass
from typing import Dict, Optional, Union
+64 -107
View File
@@ -1,39 +1,31 @@
"""core async webhook sender (aiohttp only, no discord knowledge).
`Webhook` posts a JSON dict to a url (or round-robins a pool), handling 429/5xx
retries, connection/timeout retries, and optional proxy rotation; always returns a
`WebhookResult`, never raises on a send failure. the discord layer delegates its
POST here so it inherits rotation/proxy/retry/result.
retries and optional proxy rotation, and always returns a `WebhookResult` — it
never raises on a send failure. the discord layer builds payloads and delegates
the actual POST here so it inherits rotation / proxy / retry / result.
"""
import asyncio
import logging
import math
import time
from typing import Dict, List, Optional, Union
from urllib.parse import unquote, urlsplit
import aiohttp
from commons import aretry
from .errors import NoUrlsError
from .result import WebhookResult
log = logging.getLogger(__name__)
MAX_RETRY_AFTER = 300.0
"""ceiling (seconds) honored from a 429 retry_after/Retry-After; non-finite values (inf/nan) rejected outright"""
class _Retryable(Exception):
"""internal signal for commons.aretry on a retryable 429/5xx, carrying the real response"""
def __init__(self, result: WebhookResult):
super().__init__(f"retryable status {result.status}")
self.result = result
def _proxy_string(proxies_dict: Optional[Dict[str, str]]) -> Optional[str]:
"""canonical host:port:user:pass (or host:port) from an aiohttp proxies dict, or None if unparseable"""
"""canonical host:port:user:pass (or host:port) from an aiohttp proxies dict
duck-typed: reads whatever the provider's get() returned without importing it.
returns None if the dict is empty or unparseable.
"""
if not proxies_dict:
return None
url = proxies_dict.get("http") or proxies_dict.get("https")
@@ -45,12 +37,12 @@ def _proxy_string(proxies_dict: Optional[Dict[str, str]]) -> Optional[str]:
if host is None:
return None
host = host.lower()
hostport = f"{host}:{parts.port}" if parts.port is not None else host
port = str(parts.port) if parts.port is not None else ""
if parts.username:
user = unquote(parts.username)
password = unquote(parts.password) if parts.password is not None else ""
return f"{hostport}:{user}:{password}"
return hostport
return f"{host}:{port}:{user}:{password}"
return f"{host}:{port}"
except ValueError:
return None
@@ -67,6 +59,7 @@ class Webhook:
timeout: float = 15,
max_retries: int = 3,
max_proxy_retries: int = 3,
clock=time.monotonic,
):
self._urls = [urls] if isinstance(urls, str) else list(urls)
if not self._urls:
@@ -76,6 +69,7 @@ class Webhook:
self.timeout = timeout
self.max_retries = max_retries
self.max_proxy_retries = max_proxy_retries
self._clock = clock
self._index = 0
def _next_url(self) -> str:
@@ -86,22 +80,18 @@ class Webhook:
@staticmethod
def _retry_after(status: int, headers, body) -> Optional[float]:
"""seconds to wait on a 429 from body retry_after then Retry-After header, clamped to MAX_RETRY_AFTER"""
"""seconds to wait on a 429, from body retry_after then Retry-After header"""
if status != 429:
return None
if isinstance(body, dict) and body.get("retry_after") is not None:
try:
value = float(body["retry_after"])
if math.isfinite(value):
return max(0.0, min(value, MAX_RETRY_AFTER))
return float(body["retry_after"])
except (TypeError, ValueError):
pass
header = headers.get("Retry-After") if headers else None
if header is not None:
try:
value = float(header)
if math.isfinite(value):
return max(0.0, min(value, MAX_RETRY_AFTER))
return float(header)
except (TypeError, ValueError):
pass
return None
@@ -110,10 +100,9 @@ class Webhook:
"""post a json payload to the next pool url; always returns a result
handles 429 (wait + retry, capped by max_retries), 5xx (retry, capped), and
timeout/connection errors: with a proxy provider, burn + rotate to the next
proxy (capped by max_proxy_retries) before failing; without one, retried
directly under max_retries like a 5xx. 4xx other than 429 fail immediately.
never raises on a send failure.
timeout/connection errors with optional proxy rotation (burn + next, capped
by max_proxy_retries). 4xx other than 429 fail immediately. never raises on a
send failure.
"""
url = self._next_url()
session = self._session
@@ -129,61 +118,29 @@ class Webhook:
async def _send_loop(
self, session: aiohttp.ClientSession, url: str, payload: Dict
) -> WebhookResult:
"""status-retry (commons.aretry, on _Retryable) wrapping proxy rotation; never raises"""
counter = [0]
pending_wait: List[Optional[float]] = [None]
try:
return await aretry(
lambda: self._attempt(session, url, payload, counter, pending_wait),
attempts=self.max_retries + 1,
on=(_Retryable,),
)
except _Retryable as exhausted:
return exhausted.result
except Exception as error:
# never-raises net: anything else (closed session, bad proxy url, ...) -> failed result
log.warning("webhook send failed unexpectedly on %s: %s", url, error, exc_info=True)
return WebhookResult(
ok=False, status=None, url=url, attempts=counter[0] or 1,
error=f"{type(error).__name__}: {error}",
)
async def _attempt(
self, session: aiohttp.ClientSession, url: str, payload: Dict, counter: List[int],
pending_wait: List[Optional[float]],
) -> WebhookResult:
"""one logical send: proxy rotation + a single POST; may raise _Retryable
`counter`/`pending_wait` are per-call mutable cells threaded from `_send_loop`, not
instance state - a plain local wouldn't survive aretry calling this fresh each retry.
"""
timeout = aiohttp.ClientTimeout(total=self.timeout)
last_proxy: Optional[str] = None
"""retry/rotation loop for a single send"""
attempts = 0
retries = 0
proxy_tries = 0
last_proxy: Optional[str] = None
timeout = aiohttp.ClientTimeout(total=self.timeout)
while True:
if pending_wait[0] is not None:
wait = pending_wait[0]
pending_wait[0] = None
log.warning("webhook 429 on %s; honoring retry_after %.3fs", url, wait)
await asyncio.sleep(wait)
counter[0] += 1
attempts = counter[0]
proxy_url = None
if self._proxies is not None:
try:
proxy_dict = self._proxies.get()
except Exception:
# duck-typed provider: any get() error means no proxy available
log.warning("webhook: proxy get() failed; no proxy available",
exc_info=True)
return WebhookResult(
ok=False, status=None, url=url, attempts=attempts,
error="proxies unavailable", proxy=last_proxy,
)
except Exception as error:
if type(error).__name__ == "ProxiesExhaustedError":
return WebhookResult(
ok=False, status=None, url=url, attempts=attempts,
error="proxies exhausted", proxy=last_proxy,
)
raise
last_proxy = _proxy_string(proxy_dict)
proxy_url = (proxy_dict or {}).get("http") or (proxy_dict or {}).get("https")
attempts += 1
try:
async with session.post(
url, json=payload, proxy=proxy_url, timeout=timeout
@@ -197,52 +154,52 @@ class Webhook:
response=body, proxy=last_proxy,
)
result = WebhookResult(
wait = self._retry_after(status, resp.headers, body)
if wait is not None and retries < self.max_retries:
retries += 1
log.warning("webhook 429 on %s; waiting %.3fs (retry %d/%d)",
url, wait, retries, self.max_retries)
await asyncio.sleep(wait)
continue
if status >= 500 and retries < self.max_retries:
retries += 1
log.warning("webhook %d on %s; retry %d/%d",
status, url, retries, self.max_retries)
continue
return WebhookResult(
ok=False, status=status, url=url, attempts=attempts,
error=f"http {status}", response=body, proxy=last_proxy,
)
if status == 429:
# every 429 retries; retry_after (if any) is carried to the NEXT
# attempt's sleep, additive with aretry's backoff
wait = self._retry_after(status, resp.headers, body)
if wait is not None:
pending_wait[0] = wait
else:
log.warning("webhook 429 on %s; no retry_after, backing off", url)
raise _Retryable(result)
if status >= 500:
raise _Retryable(result)
return result
except (aiohttp.ClientError, asyncio.TimeoutError) as error:
if self._proxies is not None:
if proxy_tries < self.max_proxy_retries:
if self._burn(last_proxy):
proxy_tries += 1
continue
return WebhookResult(
ok=False, status=None, url=url, attempts=attempts,
error="proxies exhausted", proxy=last_proxy,
)
if self._proxies is not None and proxy_tries < self.max_proxy_retries:
if self._burn(last_proxy):
proxy_tries += 1
continue
return WebhookResult(
ok=False, status=None, url=url, attempts=attempts,
error=f"{type(error).__name__}: {error}", proxy=last_proxy,
error="proxies exhausted", proxy=last_proxy,
)
# no proxy provider: retry like a 5xx, under aretry's backoff + max_retries
raise _Retryable(WebhookResult(
return WebhookResult(
ok=False, status=None, url=url, attempts=attempts,
error=f"{type(error).__name__}: {error}", proxy=last_proxy,
))
)
def _burn(self, proxy: Optional[str]) -> bool:
"""burn the current proxy; return False (never raise) if it can't be rotated"""
"""burn the current proxy; return False if the provider is exhausted
catches a provider ProxiesExhaustedError duck-typed by class name (the
provider is never imported), so a dying pool ends the loop cleanly.
"""
try:
self._proxies.burn(proxy)
return True
except Exception:
log.warning("webhook: proxy burn failed; ending rotation", exc_info=True)
return False
except Exception as error:
if type(error).__name__ == "ProxiesExhaustedError":
return False
raise
@staticmethod
async def _read_body(resp) -> Optional[Union[str, Dict]]: