add package: pyproject + src (core Webhook, WebhookResult, errors, discord layer)

Signed-off-by: disqualifier <disqualifierca@gmail.com>
This commit is contained in:
disqualifier 2026-06-25 14:50:05 -04:00
parent 818d9ab31d
commit 23b1ae64ed
6 changed files with 392 additions and 0 deletions

20
pyproject.toml Normal file
View File

@ -0,0 +1,20 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "aiowebhooks"
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",
]
[project.optional-dependencies]
discord = [
"discord.py>=2.3",
]
[tool.hatch.build.targets.wheel]
packages = ["src/aiowebhooks"]

View File

@ -0,0 +1,24 @@
"""aiowebhooks — async webhook sender (aiohttp), optional discord.py embeds.
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 .errors import NoUrlsError, WebhookError
from .result import WebhookResult
from .sender import Webhook
__all__ = ["Webhook", "WebhookResult", "WebhookError", "NoUrlsError"]
__version__ = "0.1.0"

View File

@ -0,0 +1,88 @@
"""discord.py conveniences over the core sender ([discord] extra).
`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 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
from typing import List, Optional, Union
from .result import WebhookResult
from .sender import Webhook
try:
import discord
_HAVE_DISCORD = True
except ImportError:
_HAVE_DISCORD = False
log = logging.getLogger(__name__)
_MISSING = "discord support requires aiowebhooks[discord]"
class DiscordWebhook:
"""discord webhook sender — builds payloads, delegates sending to a core Webhook"""
def __init__(
self,
urls,
*,
username: Optional[str] = None,
avatar_url: Optional[str] = None,
session=None,
proxies: object = None,
**core_kwargs,
):
if not _HAVE_DISCORD:
raise RuntimeError(_MISSING)
self.username = username
self.avatar_url = avatar_url
self._webhook = Webhook(urls, session=session, proxies=proxies, **core_kwargs)
@staticmethod
def _embed_to_dict(embed) -> dict:
"""normalize a discord.Embed or a raw dict to a payload dict"""
if isinstance(embed, dict):
return embed
if hasattr(embed, "to_dict"):
return embed.to_dict()
raise TypeError("embeds must be discord.Embed objects or dicts")
def _build(
self,
content: Optional[str],
embeds: Optional[List[Union[dict, "discord.Embed"]]],
username: Optional[str],
avatar_url: Optional[str],
) -> dict:
"""assemble the discord webhook json from identity + content + embeds"""
payload: dict = {}
name = username if username is not None else self.username
avatar = avatar_url if avatar_url is not None else self.avatar_url
if name is not None:
payload["username"] = name
if avatar is not None:
payload["avatar_url"] = avatar
if content is not None:
payload["content"] = content
if embeds:
payload["embeds"] = [self._embed_to_dict(e) for e in embeds]
return payload
async def send(
self,
content: Optional[str] = None,
*,
embeds: Optional[List[Union[dict, "discord.Embed"]]] = None,
username: Optional[str] = None,
avatar_url: Optional[str] = None,
) -> WebhookResult:
"""build the discord payload and send it through the core webhook"""
if not _HAVE_DISCORD:
raise RuntimeError(_MISSING)
payload = self._build(content, embeds, username, avatar_url)
return await self._webhook.send(payload)

16
src/aiowebhooks/errors.py Normal file
View File

@ -0,0 +1,16 @@
"""exception types for aiowebhooks.
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.
"""
class WebhookError(Exception):
"""base for aiowebhooks errors"""
class NoUrlsError(WebhookError):
"""raised when a Webhook is constructed with no urls"""

30
src/aiowebhooks/result.py Normal file
View File

@ -0,0 +1,30 @@
"""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
@dataclass
class WebhookResult:
"""outcome of a webhook send attempt
`ok` is the single branch point. `status` is the final HTTP status (None if the
request never completed, e.g. a timeout). `url` is the pool url that was used.
`attempts` counts total tries across retries/proxy rotations. `error` is a short
cause string on failure (None on success). `response` is the response payload
(parsed json if possible, else text, None if none). `proxy` is the canonical
proxy string used for the final attempt, or None if no proxy provider.
"""
ok: bool
status: Optional[int] = None
url: str = ""
attempts: int = 0
error: Optional[str] = None
response: Optional[Union[str, Dict]] = None
proxy: Optional[str] = None

214
src/aiowebhooks/sender.py Normal file
View File

@ -0,0 +1,214 @@
"""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 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 time
from typing import Dict, List, Optional, Union
from urllib.parse import unquote, urlsplit
import aiohttp
from .errors import NoUrlsError
from .result import WebhookResult
log = logging.getLogger(__name__)
def _proxy_string(proxies_dict: Optional[Dict[str, str]]) -> Optional[str]:
"""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")
if not url:
return None
try:
parts = urlsplit(url)
host = parts.hostname
if host is None:
return None
host = host.lower()
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"{host}:{port}:{user}:{password}"
return f"{host}:{port}"
except ValueError:
return None
class Webhook:
"""generic async webhook sender over a round-robin url pool"""
def __init__(
self,
urls: Union[str, List[str]],
*,
session: Optional[aiohttp.ClientSession] = None,
proxies: object = None,
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:
raise NoUrlsError("Webhook requires at least one url")
self._session = session
self._proxies = proxies
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:
"""return the next url in round-robin order"""
url = self._urls[self._index]
self._index = (self._index + 1) % len(self._urls)
return url
@staticmethod
def _retry_after(status: int, headers, body) -> Optional[float]:
"""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:
return float(body["retry_after"])
except (TypeError, ValueError):
pass
header = headers.get("Retry-After") if headers else None
if header is not None:
try:
return float(header)
except (TypeError, ValueError):
pass
return None
async def send(self, payload: Dict) -> WebhookResult:
"""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 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
owns_session = session is None
if owns_session:
session = aiohttp.ClientSession()
try:
return await self._send_loop(session, url, payload)
finally:
if owns_session:
await session.close()
async def _send_loop(
self, session: aiohttp.ClientSession, url: str, payload: Dict
) -> WebhookResult:
"""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:
proxy_url = None
if self._proxies is not None:
try:
proxy_dict = self._proxies.get()
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
) as resp:
status = resp.status
body = await self._read_body(resp)
if 200 <= status < 300:
return WebhookResult(
ok=True, status=status, url=url, attempts=attempts,
response=body, proxy=last_proxy,
)
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,
)
except (aiohttp.ClientError, asyncio.TimeoutError) as error:
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="proxies exhausted", proxy=last_proxy,
)
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 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 as error:
if type(error).__name__ == "ProxiesExhaustedError":
return False
raise
@staticmethod
async def _read_body(resp) -> Optional[Union[str, Dict]]:
"""response payload as json if parseable, else text, else None"""
try:
return await resp.json(content_type=None)
except Exception:
try:
text = await resp.text()
except Exception:
return None
return text or None