3 Commits
Author SHA1 Message Date
dsql cd93e4f44f fix: per-call attempt counter so concurrent sends don't corrupt attempts
the attempt count used instance state (self._attempt_no), reset in _send_loop and incremented in _attempt; two concurrent send() calls on one Webhook interleaved, corrupting each other's WebhookResult.attempts. it is now a per-call mutable cell created in _send_loop and threaded into _attempt. verified under load: 400 concurrent sends, zero corrupted counts.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-28 17:46:20 -04:00
dsql bcf1f5511c fix: _proxy_string emits no stray colons for a portless proxy
a portless proxy url produced host::user:pass / host: (extra colons), breaking the identity match against the provider pool. the port colon is now omitted when there is no port, mirroring aioproxies' canonical key.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-28 17:18:28 -04:00
dsql aec0a5cc2b fix: remove dead clock param; pin commons v0.2.1 (v0.1.2)
the clock= constructor param was stored (self._clock) but never read — the 429
retry_after wait uses asyncio.sleep directly. it was dead code, and the CLAUDE.md
wrongly claimed it made 429 timing test-controllable. remove the param + the unused
time import, and correct the doc (tests patch commons.retry's sleep + sender.asyncio
.sleep, not a clock seam). bump the commons pin to v0.2.1 (retry attempts floor).

verified: clock param gone, constructs fine, 18/18 fix harness intact.
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-28 16:16:13 -04:00
4 changed files with 55 additions and 31 deletions
+40 -17
View File
@@ -13,13 +13,14 @@ 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.0
aiowebhooks @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiowebhooks.git@v0.1.2
# discord embeds / identity helpers need the extra:
aiowebhooks[discord] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiowebhooks.git@v0.1.0
aiowebhooks[discord] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aiowebhooks.git@v0.1.2
```
The base pulls `aiohttp`. Only `aiowebhooks[discord]` adds `discord.py` (>=2.3,
mainline — not discord.py-self), and only for `DiscordWebhook`.
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`.
## Core sender
@@ -47,7 +48,9 @@ Webhook(
```
Inject a shared `session` for throughput (one session per process); without one, each
send opens and closes its own.
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`.
## WebhookResult
@@ -65,12 +68,15 @@ result.proxy # canonical proxy string used (host:port:user:pass / host:port)
## Retry & rate limits
- **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`.
Status retries run through `commons.aretry` (exponential backoff + cap):
- **429** — honors the `retry_after` from the body first (Discord sends seconds), then
the `Retry-After` header, sleeping that exact value; capped by `max_retries`.
- **5xx** — retried with exponential backoff, 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.
Exceeding a cap returns a failed result rather than looping — and the result carries the
**real** last status/body (not a synthetic placeholder).
## Proxy rotation (optional, duck-typed)
@@ -91,9 +97,10 @@ 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`. 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.
`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 just fails after normal retry.
## Discord (`aiowebhooks[discord]`)
@@ -124,11 +131,27 @@ 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`.
- 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.
- 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.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
+2 -2
View File
@@ -4,12 +4,12 @@ build-backend = "hatchling.build"
[project]
name = "aiowebhooks"
version = "0.1.1"
version = "0.1.2"
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.0",
"commons @ git+ssh://git@git.rethinkstudios.io/rethink-public/commons.git@v0.2.1",
]
[project.optional-dependencies]
+1 -1
View File
@@ -21,4 +21,4 @@ from .sender import Webhook
__all__ = ["Webhook", "WebhookResult", "WebhookError", "NoUrlsError"]
__version__ = "0.1.1"
__version__ = "0.1.2"
+12 -11
View File
@@ -8,7 +8,6 @@ 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
@@ -50,12 +49,12 @@ def _proxy_string(proxies_dict: Optional[Dict[str, str]]) -> Optional[str]:
if host is None:
return None
host = host.lower()
port = str(parts.port) if parts.port is not None else ""
hostport = f"{host}:{parts.port}" if parts.port is not None else host
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}"
return f"{hostport}:{user}:{password}"
return hostport
except ValueError:
return None
@@ -72,7 +71,6 @@ 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:
@@ -82,7 +80,6 @@ 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:
@@ -139,10 +136,10 @@ class Webhook:
synthetic status-0). proxy rotation on connection errors lives inside the
attempt and is capped separately.
"""
self._attempt_no = 0
counter = [0]
try:
return await aretry(
lambda: self._attempt(session, url, payload),
lambda: self._attempt(session, url, payload, counter),
attempts=self.max_retries + 1,
on=(_Retryable,),
)
@@ -150,7 +147,7 @@ class Webhook:
return exhausted.result
async def _attempt(
self, session: aiohttp.ClientSession, url: str, payload: Dict
self, session: aiohttp.ClientSession, url: str, payload: Dict, counter: List[int]
) -> WebhookResult:
"""one logical send: proxy rotation + a single POST; may raise _Retryable
@@ -158,14 +155,18 @@ class Webhook:
aretry applies backoff; honors an explicit 429 retry_after by sleeping it
before signalling. returns a final WebhookResult on success or a terminal
(non-retryable) failure — never lets a provider/connection error escape.
`counter` is a per-call mutable cell ([0]) owned by the calling `_send_loop`,
so the attempt count is local to one `send()` and concurrent sends on the
same instance don't corrupt each other's tally.
"""
timeout = aiohttp.ClientTimeout(total=self.timeout)
last_proxy: Optional[str] = None
proxy_tries = 0
while True:
self._attempt_no += 1
attempts = self._attempt_no
counter[0] += 1
attempts = counter[0]
proxy_url = None
if self._proxies is not None:
try: