M-2: _attempt caught only (aiohttp.ClientError, asyncio.TimeoutError); an unexpected
error escaping the attempt (closed injected session -> RuntimeError, malformed proxy url
-> ValueError) propagated out of send(), breaking the documented 'never raises on a send
failure' contract. add an outer catch-all in _send_loop converting any such exception to a
falsy WebhookResult(ok=False), logged at warning with exc_info.
aiowebhooks-F3: README 429 section + changelog were stale vs the v0.1.3 'retry any 429'
fix; added the no-parseable-wait-still-retries wording and v0.1.3/v0.1.4 changelog entries.
verified by execution: closed-session (RuntimeError) and bad-proxy (ValueError) controls
both fire and now return ok=False instead of raising.
Signed-off-by: disqualifier <dev@disqualifier.me>
treat every status==429 as retryable: sleep only when retry_after parses, but raise
_Retryable either way so aretry's backoff + max_retries cap engages. previously a 429
with no body retry_after and no Retry-After header (edge/Cloudflare/generic webhook)
returned a terminal ok=False with no retry, contradicting the documented retry-on-429.
Signed-off-by: disqualifier <dev@disqualifier.me>
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>
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>
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>
- seam bug: _burn/get() only caught ProxiesExhaustedError, but aioproxies.burn()
raises ValueError ('proxy not in pool') which escaped send() and broke the
'never raises on send failure' contract. catch ANY exception across the
duck-typed provider seam and convert to a failed WebhookResult.
- 5xx hot loop: 5xx retries had no backoff (immediate retry, hammering the
endpoint). migrate 429/5xx retry onto commons.aretry (>=0.2.0) for correct
exponential backoff + cap.
- lost response: exhausted retries returned a synthetic status-0 result; now the
real last 4xx/5xx status + body is returned (aretry re-raises the carried
_Retryable, the loop unwraps it).
verified by execution: burn/get ValueError no longer escapes, 5xx backs off
(~1.9s over 3 retries vs ~0s hot loop), exhausted 5xx returns real 503 + body,
429 retry_after honored, 4xx/rotation/round-robin intact.
Signed-off-by: disqualifier <dev@disqualifier.me>