5 Commits
Author SHA1 Message Date
dsql 52fea6e380 fix: pin inter-lib dependencies to their v1.0.0 tags
the v1.0.0 release still pinned pre-1.0.0 sibling tags, so a fresh install dragged in
stale transitive deps. update the pin(s) to the current v1.0.x release and bump this lib
to 1.0.1 so the corrected dependency chain ships under a new tag (v1.0.0 left intact).

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-17 17:46:10 -04:00
dsql fb382f9767 release: 1.0.0
first stable release. pre-1.0.0 verification complete: all surviving MED regressions and
gaps resolved and independently re-fired, tree audited clean across the suite.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-09 18:53:15 -04:00
dsql 1662d73d36 refactor: derive __version__ from package metadata (single source)
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-03 16:59:06 -04:00
dsql 0d9d93e4c1 docs: compress residual internal docstrings
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-03 16:48:19 -04:00
dsql 7a157efc16 docs: compress prose/module docstrings, em-dash->hyphen (de-bloat wave 1)
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-03 00:15:08 -04:00
7 changed files with 50 additions and 112 deletions
+16 -4
View File
@@ -13,16 +13,16 @@ 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
aiowebhooks @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiowebhooks.git@v1.0.1
# 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@v1.0.1
```
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
`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.
Drop the `@v1.0.1` suffix from the line above to install the latest unpinned.
## Core sender
@@ -148,6 +148,18 @@ Without the extra installed, importing `aiowebhooks` still works; constructing o
## 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
+2 -2
View File
@@ -4,12 +4,12 @@ build-backend = "hatchling.build"
[project]
name = "aiowebhooks"
version = "0.1.6"
version = "1.0.1"
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+ssh://git@git.rethinkstudios.io/rethink-public/commons.git@v0.2.1",
"commons @ git+ssh://git@git.rethinkstudios.io/rethink-public/commons.git@v1.0.0",
]
[project.optional-dependencies]
+10 -15
View File
@@ -1,24 +1,19 @@
"""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 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.
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).
"""
from importlib.metadata import PackageNotFoundError, version
from .errors import NoUrlsError, WebhookError
from .result import WebhookResult
from .sender import Webhook
__all__ = ["Webhook", "WebhookResult", "WebhookError", "NoUrlsError"]
__version__ = "0.1.6"
try:
__version__ = version("aiowebhooks")
except PackageNotFoundError:
__version__ = "0.0.0+unknown"
+3 -4
View File
@@ -2,9 +2,8 @@
`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.
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.
"""
import logging
@@ -25,7 +24,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,
+3 -6
View File
@@ -1,11 +1,8 @@
"""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
cover bad construction (e.g. `NoUrlsError`) and serve as a base for any future
raising surface; the missing-`[discord]`-extra path raises a plain `RuntimeError`,
not one of these.
`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.
"""
+1 -6
View File
@@ -1,9 +1,4 @@
"""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.
"""
"""the result object every send returns; `Webhook.send` never raises, callers branch on `ok`."""
from dataclasses import dataclass
from typing import Dict, Optional, Union
+15 -75
View File
@@ -1,15 +1,9 @@
"""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, 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.
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.
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.
"""
import asyncio
@@ -27,20 +21,11 @@ from .result import WebhookResult
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.
"""
"""ceiling (seconds) honored from a 429 retry_after/Retry-After; non-finite values (inf/nan) rejected outright"""
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.
"""
"""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}")
@@ -48,11 +33,7 @@ class _Retryable(Exception):
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.
"""
"""canonical host:port:user:pass (or host:port) from an aiohttp proxies dict, or None if unparseable"""
if not proxies_dict:
return None
url = proxies_dict.get("http") or proxies_dict.get("https")
@@ -105,11 +86,7 @@ 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
non-finite values (inf/nan) are rejected as unparseable; finite values are
clamped to MAX_RETRY_AFTER.
"""
"""seconds to wait on a 429 from body retry_after then Retry-After header, clamped to MAX_RETRY_AFTER"""
if status != 429:
return None
if isinstance(body, dict) and body.get("retry_after") is not None:
@@ -152,15 +129,7 @@ class Webhook:
async def _send_loop(
self, session: aiohttp.ClientSession, url: str, payload: Dict
) -> WebhookResult:
"""status-retry (via commons.aretry) wrapping proxy rotation; never raises
commons.aretry owns the 429/5xx/no-provider-connection-error backoff schedule
+ 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.
"""
"""status-retry (commons.aretry, on _Retryable) wrapping proxy rotation; never raises"""
counter = [0]
pending_wait: List[Optional[float]] = [None]
try:
@@ -172,9 +141,7 @@ class Webhook:
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()
# 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,
@@ -187,22 +154,8 @@ class Webhook:
) -> 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.
`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
@@ -250,13 +203,8 @@ class Webhook:
)
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.
# 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
@@ -281,22 +229,14 @@ class Webhook:
ok=False, status=None, url=url, attempts=attempts,
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.
# no proxy provider: retry like a 5xx, under aretry's backoff + max_retries
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:
"""burn the current proxy; return False if it can't be rotated
the provider is duck-typed and never imported, so any exception from burn
(dead pool, unknown proxy, ...) is caught broadly and means "can't rotate"
return False rather than let it escape send().
"""
"""burn the current proxy; return False (never raise) if it can't be rotated"""
try:
self._proxies.burn(proxy)
return True