2 Commits
6 changed files with 80 additions and 262 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
# claude # claude
.claude/ CLAUDE.md
# python # python
__pycache__/ __pycache__/
+18 -92
View File
@@ -13,16 +13,13 @@ send to the core — inheriting rotation, proxy, retry, and result for free.
## Install ## Install
``` ```
aiowebhooks @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiowebhooks.git@v0.1.6 aiowebhooks @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiowebhooks.git@v0.1.0
# discord embeds / identity helpers need the extra: # discord embeds / identity helpers need the extra:
aiowebhooks[discord] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiowebhooks.git@v0.1.6 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 The base pulls `aiohttp`. Only `aiowebhooks[discord]` adds `discord.py` (>=2.3,
`aiowebhooks[discord]` adds `discord.py` (>=2.3, mainline — not discord.py-self), and mainline — not discord.py-self), and only for `DiscordWebhook`.
only for `DiscordWebhook`.
Drop the `@v0.1.6` suffix from the line above to install the latest unpinned.
## Core sender ## Core sender
@@ -50,9 +47,7 @@ Webhook(
``` ```
Inject a shared `session` for throughput (one session per process); without one, each 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 send opens and closes its own.
concurrent `send()` calls — each call tracks its own attempt count, so concurrent sends
don't corrupt each other's `WebhookResult.attempts`.
## WebhookResult ## WebhookResult
@@ -70,23 +65,12 @@ result.proxy # canonical proxy string used (host:port:user:pass / host:port)
## Retry & rate limits ## Retry & rate limits
Status retries run through `commons.aretry` (exponential backoff + cap): - **429** — waits the `retry_after` from the body first (Discord sends seconds), then
the `Retry-After` header, then retries; capped by `max_retries`.
- **429** — always retried, capped by `max_retries`. When a wait is parseable (body - **5xx** — retried, capped by `max_retries`.
`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`. - **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 Exceeding a cap returns a failed result rather than looping.
**real** last status/body (not a synthetic placeholder).
## Proxy rotation (optional, duck-typed) ## 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 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 `max_proxy_retries`. A provider `ProxiesExhaustedError` (or hitting the cap) returns a
`get()`/`burn()`** (the provider is duck-typed and never imported, so its exception failed result — never an infinite loop. With no provider, a timeout just fails after
types can't be caught by class), returns a failed result — never an infinite loop, never normal retry.
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]`) ## Discord (`aiowebhooks[discord]`)
@@ -142,68 +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 - Every send returns a `WebhookResult`; the core never raises on a send failure and
never prints. Callers check `result.ok`. never prints. Callers check `result.ok`.
- JSON-only: **files/attachments, `tts`, and `allowed_mentions` are out** (deliberate - v0.1.0 is JSON-only: **files/attachments, `tts`, and `allowed_mentions` are out**
scope cut, addable later). The Discord surface is content + embeds + identity. (deliberate scope cut, addable later). The Discord surface is content + embeds +
- Rotation is round-robin only; try-next-on-failure across URLs is a later feature. identity.
- v0.1.0 rotation is round-robin only; try-next-on-failure across URLs is a later
## Changelog feature.
### 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 ## 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] [project]
name = "aiowebhooks" name = "aiowebhooks"
version = "0.1.6" version = "0.1.0"
description = "async webhook sender (aiohttp) with round-robin urls, retry, and proxy rotation; optional discord.py embeds" description = "async webhook sender (aiohttp) with round-robin urls, retry, and proxy rotation; optional discord.py embeds"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"aiohttp>=3.9", "aiohttp>=3.9",
"commons @ git+ssh://git@git.rethinkstudios.io/rethink-public/commons.git@v0.2.1",
] ]
[project.optional-dependencies] [project.optional-dependencies]
@@ -17,8 +16,5 @@ discord = [
"discord.py>=2.3", "discord.py>=2.3",
] ]
[tool.hatch.metadata]
allow-direct-references = true
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["src/aiowebhooks"] packages = ["src/aiowebhooks"]
+1 -1
View File
@@ -21,4 +21,4 @@ from .sender import Webhook
__all__ = ["Webhook", "WebhookResult", "WebhookError", "NoUrlsError"] __all__ = ["Webhook", "WebhookResult", "WebhookError", "NoUrlsError"]
__version__ = "0.1.6" __version__ = "0.1.0"
+2 -3
View File
@@ -3,9 +3,8 @@
these are surfaced for callers that want to branch on a specific failure cause. 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 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 `WebhookResult` with `ok=False` and the cause captured in `error`. these types
cover bad construction (e.g. `NoUrlsError`) and serve as a base for any future exist for the few raise paths (bad construction, missing extra) and as a base for
raising surface; the missing-`[discord]`-extra path raises a plain `RuntimeError`, any future raising surface.
not one of these.
""" """
+47 -150
View File
@@ -1,51 +1,24 @@
"""core async webhook sender (aiohttp only, no discord knowledge). """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 `Webhook` posts a JSON dict to a url (or round-robins a pool), handling 429/5xx
retries, connection/timeout retries, and optional proxy rotation, and always retries and optional proxy rotation, and always returns a `WebhookResult` — it
returns a `WebhookResult` — it never raises on a send failure. the discord layer never raises on a send failure. the discord layer builds payloads and delegates
builds payloads and delegates the actual POST here so it inherits rotation / proxy the actual POST here so it inherits rotation / proxy / retry / result.
/ retry / result.
a 429's server-controlled `retry_after` wait is bounded: non-finite values
(inf/nan) are rejected and finite values are clamped to `MAX_RETRY_AFTER`, so an
adversarial or misconfigured server can never stall a send() past a bounded
ceiling.
""" """
import asyncio import asyncio
import logging import logging
import math import time
from typing import Dict, List, Optional, Union from typing import Dict, List, Optional, Union
from urllib.parse import unquote, urlsplit from urllib.parse import unquote, urlsplit
import aiohttp import aiohttp
from commons import aretry
from .errors import NoUrlsError from .errors import NoUrlsError
from .result import WebhookResult from .result import WebhookResult
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
MAX_RETRY_AFTER = 300.0
"""ceiling (seconds) honored from a server-controlled 429 retry_after/Retry-After
guards against an arbitrarily large or non-finite wait (inf/nan, or a ms-vs-s unit
mismatch) stalling a send() past its ClientTimeout. non-finite values are rejected
outright; finite values are clamped to this ceiling.
"""
class _Retryable(Exception):
"""internal signal: a retryable HTTP status (429/5xx); carries the response
raised inside an attempt so commons.aretry drives the backoff + cap; the loop
catches the final one to return the REAL last response, not a synthetic result.
"""
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]: def _proxy_string(proxies_dict: Optional[Dict[str, str]]) -> Optional[str]:
"""canonical host:port:user:pass (or host:port) from an aiohttp proxies dict """canonical host:port:user:pass (or host:port) from an aiohttp proxies dict
@@ -64,12 +37,12 @@ def _proxy_string(proxies_dict: Optional[Dict[str, str]]) -> Optional[str]:
if host is None: if host is None:
return None return None
host = host.lower() 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: if parts.username:
user = unquote(parts.username) user = unquote(parts.username)
password = unquote(parts.password) if parts.password is not None else "" password = unquote(parts.password) if parts.password is not None else ""
return f"{hostport}:{user}:{password}" return f"{host}:{port}:{user}:{password}"
return hostport return f"{host}:{port}"
except ValueError: except ValueError:
return None return None
@@ -86,6 +59,7 @@ class Webhook:
timeout: float = 15, timeout: float = 15,
max_retries: int = 3, max_retries: int = 3,
max_proxy_retries: int = 3, max_proxy_retries: int = 3,
clock=time.monotonic,
): ):
self._urls = [urls] if isinstance(urls, str) else list(urls) self._urls = [urls] if isinstance(urls, str) else list(urls)
if not self._urls: if not self._urls:
@@ -95,6 +69,7 @@ class Webhook:
self.timeout = timeout self.timeout = timeout
self.max_retries = max_retries self.max_retries = max_retries
self.max_proxy_retries = max_proxy_retries self.max_proxy_retries = max_proxy_retries
self._clock = clock
self._index = 0 self._index = 0
def _next_url(self) -> str: def _next_url(self) -> str:
@@ -105,26 +80,18 @@ class Webhook:
@staticmethod @staticmethod
def _retry_after(status: int, headers, body) -> Optional[float]: def _retry_after(status: int, headers, body) -> Optional[float]:
"""seconds to wait on a 429, from body retry_after then Retry-After header """seconds to wait on a 429, from body retry_after then Retry-After header"""
non-finite values (inf/nan) are rejected as unparseable; finite values are
clamped to MAX_RETRY_AFTER.
"""
if status != 429: if status != 429:
return None return None
if isinstance(body, dict) and body.get("retry_after") is not None: if isinstance(body, dict) and body.get("retry_after") is not None:
try: try:
value = float(body["retry_after"]) return float(body["retry_after"])
if math.isfinite(value):
return max(0.0, min(value, MAX_RETRY_AFTER))
except (TypeError, ValueError): except (TypeError, ValueError):
pass pass
header = headers.get("Retry-After") if headers else None header = headers.get("Retry-After") if headers else None
if header is not None: if header is not None:
try: try:
value = float(header) return float(header)
if math.isfinite(value):
return max(0.0, min(value, MAX_RETRY_AFTER))
except (TypeError, ValueError): except (TypeError, ValueError):
pass pass
return None return None
@@ -133,10 +100,9 @@ class Webhook:
"""post a json payload to the next pool url; always returns a result """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 handles 429 (wait + retry, capped by max_retries), 5xx (retry, capped), and
timeout/connection errors: with a proxy provider, burn + rotate to the next timeout/connection errors with optional proxy rotation (burn + next, capped
proxy (capped by max_proxy_retries) before failing; without one, retried by max_proxy_retries). 4xx other than 429 fail immediately. never raises on a
directly under max_retries like a 5xx. 4xx other than 429 fail immediately. send failure.
never raises on a send failure.
""" """
url = self._next_url() url = self._next_url()
session = self._session session = self._session
@@ -152,85 +118,29 @@ class Webhook:
async def _send_loop( async def _send_loop(
self, session: aiohttp.ClientSession, url: str, payload: Dict self, session: aiohttp.ClientSession, url: str, payload: Dict
) -> WebhookResult: ) -> WebhookResult:
"""status-retry (via commons.aretry) wrapping proxy rotation; never raises """retry/rotation loop for a single send"""
attempts = 0
commons.aretry owns the 429/5xx/no-provider-connection-error backoff schedule retries = 0
+ retry cap (max_retries), retrying on the internal _Retryable signal; on
exhaustion it re-raises the last _Retryable, whose carried result is the real
last response (not a synthetic status-0). proxy rotation on connection errors
lives inside the attempt, capped separately (max_proxy_retries); once that cap
is hit the failure is terminal, not retried again via aretry.
"""
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 safety net: anything not aiohttp.ClientError/TimeoutError
# (closed injected session, malformed proxy url, ...) comes back as a
# failed result instead of escaping send()
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
raises _Retryable (carrying the real response) on a 429/5xx so the caller's
aretry applies backoff. an explicit 429 retry_after is carried over and slept
at the START of the next attempt, never after the one that raises, so an
exhausted final attempt never sleeps a wait it won't use. a connection/timeout
error also raises _Retryable when no proxy provider is set (retries under
aretry like a 5xx); with a provider it instead drives burn+rotate up to
max_proxy_retries and only becomes terminal once that cap is hit. always
returns a final WebhookResult or raises _Retryable — never lets a provider
error escape.
`counter`/`pending_wait` are per-call mutable cells ([0] / [None]) owned by
`_send_loop` and threaded through because aretry calls `_attempt` fresh on
every top-level retry, so a plain local wouldn't survive across calls.
`counter` tallies attempts; `pending_wait` carries a 429 retry_after to the
next attempt's sleep. keeps both local to one `send()`, safe for concurrent
sends on the same instance.
"""
timeout = aiohttp.ClientTimeout(total=self.timeout)
last_proxy: Optional[str] = None
proxy_tries = 0 proxy_tries = 0
last_proxy: Optional[str] = None
timeout = aiohttp.ClientTimeout(total=self.timeout)
while True: 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 proxy_url = None
if self._proxies is not None: if self._proxies is not None:
try: try:
proxy_dict = self._proxies.get() proxy_dict = self._proxies.get()
except Exception: except Exception as error:
# duck-typed provider: any get() error means no proxy available if type(error).__name__ == "ProxiesExhaustedError":
log.warning("webhook: proxy get() failed; no proxy available",
exc_info=True)
return WebhookResult( return WebhookResult(
ok=False, status=None, url=url, attempts=attempts, ok=False, status=None, url=url, attempts=attempts,
error="proxies unavailable", proxy=last_proxy, error="proxies exhausted", proxy=last_proxy,
) )
raise
last_proxy = _proxy_string(proxy_dict) last_proxy = _proxy_string(proxy_dict)
proxy_url = (proxy_dict or {}).get("http") or (proxy_dict or {}).get("https") proxy_url = (proxy_dict or {}).get("http") or (proxy_dict or {}).get("https")
attempts += 1
try: try:
async with session.post( async with session.post(
url, json=payload, proxy=proxy_url, timeout=timeout url, json=payload, proxy=proxy_url, timeout=timeout
@@ -244,32 +154,27 @@ class Webhook:
response=body, proxy=last_proxy, 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, ok=False, status=status, url=url, attempts=attempts,
error=f"http {status}", response=body, proxy=last_proxy, error=f"http {status}", response=body, proxy=last_proxy,
) )
if status == 429:
# every 429 is retryable; an explicit retry_after is carried
# to the NEXT attempt's sleep (never slept here), so an
# exhausted final attempt never sleeps a wait it won't use.
# no parseable wait still retries under aretry's backoff+cap;
# an honored wait is additive with that backoff (over-waits,
# never under-waits). _retry_after rejects non-finite values
# and clamps finite ones to MAX_RETRY_AFTER.
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: except (aiohttp.ClientError, asyncio.TimeoutError) as error:
if self._proxies is not None: if self._proxies is not None and proxy_tries < self.max_proxy_retries:
if proxy_tries < self.max_proxy_retries:
if self._burn(last_proxy): if self._burn(last_proxy):
proxy_tries += 1 proxy_tries += 1
continue continue
@@ -281,28 +186,20 @@ class Webhook:
ok=False, status=None, url=url, attempts=attempts, ok=False, status=None, url=url, attempts=attempts,
error=f"{type(error).__name__}: {error}", proxy=last_proxy, error=f"{type(error).__name__}: {error}", proxy=last_proxy,
) )
# no proxy provider: retry a connection/timeout error like a 5xx,
# under aretry's backoff + max_retries instead of failing one-shot;
# _send_loop's `except _Retryable` returns the carried result once
# exhausted, so never-raises still holds.
raise _Retryable(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: def _burn(self, proxy: Optional[str]) -> bool:
"""burn the current proxy; return False if it can't be rotated """burn the current proxy; return False if the provider is exhausted
the provider is duck-typed and never imported, so any exception from burn catches a provider ProxiesExhaustedError duck-typed by class name (the
(dead pool, unknown proxy, ...) is caught broadly and means "can't rotate" provider is never imported), so a dying pool ends the loop cleanly.
return False rather than let it escape send().
""" """
try: try:
self._proxies.burn(proxy) self._proxies.burn(proxy)
return True return True
except Exception: except Exception as error:
log.warning("webhook: proxy burn failed; ending rotation", exc_info=True) if type(error).__name__ == "ProxiesExhaustedError":
return False return False
raise
@staticmethod @staticmethod
async def _read_body(resp) -> Optional[Union[str, Dict]]: async def _read_body(resp) -> Optional[Union[str, Dict]]: