From cd93e4f44f0fb13670579dfdde0a2b409c667ad9 Mon Sep 17 00:00:00 2001 From: disqualifier Date: Sun, 28 Jun 2026 17:46:20 -0400 Subject: [PATCH] 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 --- README.md | 4 +++- src/aiowebhooks/sender.py | 14 +++++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index f463cfd..33e0c43 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/aiowebhooks/sender.py b/src/aiowebhooks/sender.py index a926bcb..ee8f81b 100644 --- a/src/aiowebhooks/sender.py +++ b/src/aiowebhooks/sender.py @@ -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: