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>
A 429 retry_after was slept immediately, including on the final attempt
that goes on to exhaust max_retries — a pointless wait right before
giving up. The wait is now carried in a per-call mutable cell and slept
at the start of the next attempt instead, so it's honored before every
attempt that actually runs and never after the last one.
errors.py's docstring claimed the exported error types cover the
missing-[discord]-extra raise path; that raise is a plain RuntimeError,
not one of these types. Reworded to match.
Compressed essay-length docstrings/comments across sender.py; no
behavior change. Verified against the aioproxies twin: aiowebhooks'
proxy-key normalization already routes zero-padded ports through
urlsplit().port (parses to int, no zero-pad on render), so host:080
and host:80 already collapse to one canonical key — no code change
needed there.
Signed-off-by: disqualifier <dev@disqualifier.me>
A server-controlled 429 retry_after/Retry-After was slept verbatim with no
finiteness check or ceiling, so an inf or huge value (adversarial or a
ms-vs-s unit mismatch) could stall send() for hours outside max_retries
accounting. Non-finite values are now rejected and finite values clamped
to MAX_RETRY_AFTER (300s).
Connection/timeout errors with no proxy provider set failed one-shot,
contradicting the README's documented "normal retry" behavior and skipping
the single most retry-worthy failure class. They now raise the internal
_Retryable signal so commons.aretry retries them under max_retries, same
as a 5xx, while still returning a failed WebhookResult (never raising)
once retries are exhausted.
Signed-off-by: disqualifier <dev@disqualifier.me>
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 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>