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>
This commit is contained in:
disqualifier 2026-06-28 17:46:20 -04:00
parent bcf1f5511c
commit cd93e4f44f
2 changed files with 12 additions and 6 deletions

View File

@ -48,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

View File

@ -136,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,),
)
@ -147,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
@ -155,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: