8 Commits
Author SHA1 Message Date
dsql c65326882a chore: bump to 1.1.0 (logging-discipline audit)
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-08-10 23:00:50 -04:00
dsql e2f78bd80f fix: logging discipline in the webhook send loop
- promote the three swallow-to-failed-result catch-alls (unexpected send error, proxy
  get() fault, proxy burn() fault) from WARNING to ERROR: each swallows an unexpected
  failure into a WebhookResult(ok=False) - the op failed, the result is lost, and this
  lib is the last code that sees the exception, so it is an ERROR not a routine WARNING.
- add the attempt count to the two per-attempt 429 WARNING lines (rule 3: per-attempt
  retry lines carry N/M), using the per-call counter already in scope.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-08-09 02:09:53 -04:00
dsql 8a8d99bbb3 build: use git+https for inter-lib deps (docker ssh limitation)
docker builds can't use git+ssh (no ssh key / agent in the build), so the inter-lib
dependency references move to git+https (repos are public, anonymous clone). pins are
unchanged in target; bump to 1.0.2 so the https dependency spec ships under a new tag.
README install lines intentionally keep the ssh form for local/dev use.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-20 22:25:06 -04:00
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 65 additions and 119 deletions

No files matched your search

+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.2
# 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.2
```
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.2` 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.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+ssh://git@git.rethinkstudios.io/rethink-public/commons.git@v0.2.1",
"commons @ git+https://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
+30 -82
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,10 +141,10 @@ 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()
log.warning("webhook send failed unexpectedly on %s: %s", url, error, exc_info=True)
# never-raises net: anything else (closed session, bad proxy url, ...) -> failed result.
# ERROR: an UNEXPECTED failure is swallowed here into a failed result (op failed, result
# lost, a human may need to act) - the lib is the last code that sees this exception.
log.error("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}",
@@ -187,22 +156,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
@@ -212,7 +167,8 @@ class Webhook:
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)
log.warning("webhook 429 on %s; honoring retry_after %.3fs (attempt %d/%d)",
url, wait, counter[0] + 1, self.max_retries + 1)
await asyncio.sleep(wait)
counter[0] += 1
attempts = counter[0]
@@ -221,9 +177,11 @@ class Webhook:
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)
# duck-typed provider: any get() error means no proxy available.
# ERROR: swallowed into a failed result (op failed, result lost) - the
# provider fault vanishes here unless it is logged loudly.
log.error("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,
@@ -250,18 +208,14 @@ 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
else:
log.warning("webhook 429 on %s; no retry_after, backing off", url)
log.warning("webhook 429 on %s; no retry_after, backing off (attempt %d/%d)",
url, attempts, self.max_retries + 1)
raise _Retryable(result)
if status >= 500:
raise _Retryable(result)
@@ -281,27 +235,21 @@ 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
except Exception:
log.warning("webhook: proxy burn failed; ending rotation", exc_info=True)
# ERROR: an unexpected provider fault (burn() raising) is swallowed here and ends
# rotation, failing the send - it must be loud, not a quiet WARNING.
log.error("webhook: proxy burn failed; ending rotation", exc_info=True)
return False
@staticmethod