1 Commits
Author SHA1 Message Date
dsql a28ebfc593 release: 1.0.0
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>
2026-07-06 21:21:00 -04:00
3 changed files with 26 additions and 84 deletions

No files matched your search

+3 -19
View File
@@ -11,19 +11,19 @@ and swap the HTTP client while inheriting everything else.
`requirements.txt`: `requirements.txt`:
``` ```
aioweb @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioweb.git@v1.3.0 aioweb @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioweb.git@v0.1.13
``` ```
Direct: Direct:
```bash ```bash
pip install "aioweb @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioweb.git@v1.3.0" pip install "aioweb @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioweb.git@v0.1.13"
``` ```
Requires `aiohttp` and `yarl` (pulled transitively), plus the sibling `commons` library Requires `aiohttp` and `yarl` (pulled transitively), plus the sibling `commons` library
(a private `git+ssh` package — the install needs access to the `rethink-public` org). (a private `git+ssh` package — the install needs access to the `rethink-public` org).
Drop the `@v1.3.0` suffix from the line above to install the latest unpinned. Drop the `@v0.1.13` suffix from the line above to install the latest unpinned.
## Usage ## Usage
@@ -150,22 +150,6 @@ Two changes can't be shimmed without re-introducing the bugs they fix:
## Changelog ## Changelog
### v1.1.0
- **Hang-guard on `request()` — a wedged backend can no longer park the loop forever.**
`request()` now wraps the backend call (`_raw_request`) in an outer `asyncio.wait_for`
deadline sized at the effective timeout **+ 5s slack** (`_HANG_GUARD_SLACK`). The
backend's own timeout still fires first in every healthy failure (slack, not
replacement); the envelope only trips when the backend's timer is dead — e.g. a
`curl_cffi` transfer that loses both its wakeup and its own enforcement in the same
failure — and its cancellation is what unwedges the orphaned transfer. The guard's
deadline is the per-call numeric `timeout` when given, else the session timeout
(`Session(timeout=...)`, default 10s); it composes cleanly with `request_with_retries`
(a wedge becomes one logged `ServerTimeoutError`, the next attempt proceeds, and an
exhausted retry returns a `FailureResponse` rather than hanging). **Opt-out:** a
session deliberately constructed unbounded (`Session(timeout=None)`) stays unguarded,
for genuinely long-lived calls (long-poll / streaming). Additive; no new knob.
### v0.1.13 ### v0.1.13
- **Session-default timeout no longer poisons pooled keep-alive connections.** - **Session-default timeout no longer poisons pooled keep-alive connections.**
+2 -2
View File
@@ -4,13 +4,13 @@ build-backend = "hatchling.build"
[project] [project]
name = "aioweb" name = "aioweb"
version = "1.3.0" version = "1.0.0"
description = "Async HTTP session wrapper over aiohttp — proxies, header overwrites, retries, previews. Config-free, installable." description = "Async HTTP session wrapper over aiohttp — proxies, header overwrites, retries, previews. Config-free, installable."
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"aiohttp>=3.9", "aiohttp>=3.9",
"yarl>=1.9", "yarl>=1.9",
"commons @ git+https://git.rethinkstudios.io/rethink-public/commons.git@v1.0.0", "commons @ git+ssh://git@git.rethinkstudios.io/rethink-public/commons.git@v0.2.1",
] ]
[tool.hatch.metadata] [tool.hatch.metadata]
+21 -63
View File
@@ -15,7 +15,6 @@ import warnings
from http.cookies import SimpleCookie from http.cookies import SimpleCookie
import aiohttp import aiohttp
from multidict import CIMultiDict
from yarl import URL from yarl import URL
from commons import aretry from commons import aretry
@@ -64,7 +63,6 @@ log = logging.getLogger(__name__)
DEFAULT_ATTEMPTS = 3 DEFAULT_ATTEMPTS = 3
DEFAULT_BACKOFF_BASE = 2.0 DEFAULT_BACKOFF_BASE = 2.0
RETRY_STATUSES = frozenset({429, 500, 502, 503, 504}) RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
_HANG_GUARD_SLACK = 5
class ExtendedSession: class ExtendedSession:
@@ -86,15 +84,12 @@ class ExtendedSession:
kwargs: passed through to aiohttp.ClientSession kwargs: passed through to aiohttp.ClientSession
""" """
self.inject = inject self.inject = inject
# copied, not aliased: a shared config dict must not be mutated by (or leak a self.header_overwrites = overwrites or {}
# clear_proxies() into) a sibling session self.domain_overwrites = domain_overwrites or {}
self.header_overwrites = dict(overwrites or {})
self.domain_overwrites = dict(domain_overwrites or {})
self.ephemeral_headers = {} self.ephemeral_headers = {}
self.proxies = dict(proxies or {}) self.proxies = proxies or {}
# own header layer, not aiohttp privates, so update_headers/clear_headers work; # own header layer, not aiohttp privates, so update_headers/clear_headers work
# case-insensitive like HTTP header names self._default_headers = dict(headers or {})
self._default_headers = CIMultiDict(headers or {})
self._session_timeout = timeout self._session_timeout = timeout
self._session_kwargs = kwargs self._session_kwargs = kwargs
# aiohttp>=3.14 needs a running loop to build ClientSession; built lazily instead # aiohttp>=3.14 needs a running loop to build ClientSession; built lazily instead
@@ -143,12 +138,8 @@ class ExtendedSession:
self.header_overwrites[header] = value self.header_overwrites[header] = value
def _apply_overwrites(self, request_headers): def _apply_overwrites(self, request_headers):
"""apply static overwrites and ephemeral headers to a request's headers """apply static overwrites and ephemeral headers to a request's headers"""
request_headers = dict(request_headers or {})
header names match case-insensitively, so an overwrite replaces a `user-agent`
rather than sending it alongside a second `User-Agent`
"""
request_headers = CIMultiDict(request_headers or {})
# list()-snapshot so a concurrent mutation of the shared dicts can't raise # list()-snapshot so a concurrent mutation of the shared dicts can't raise
# "dict changed size during iteration" # "dict changed size during iteration"
for header, value in list(self.header_overwrites.items()): for header, value in list(self.header_overwrites.items()):
@@ -161,7 +152,7 @@ class ExtendedSession:
request_headers.update(value) request_headers.update(value)
else: else:
request_headers[header] = value request_headers[header] = value
return dict(request_headers) return request_headers
def set_ephemeral(self, header, value_callable): def set_ephemeral(self, header, value_callable):
"""register a header generated fresh (via callable) for each request""" """register a header generated fresh (via callable) for each request"""
@@ -270,7 +261,7 @@ class ExtendedSession:
falling back to them. falling back to them.
""" """
proxies = self.proxies if proxies is None else proxies proxies = self.proxies if proxies is None else proxies
scheme = URL(url).scheme scheme = url.split("://")[0]
return proxies.get(scheme) return proxies.get(scheme)
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -361,8 +352,7 @@ class ExtendedSession:
""" """
url = self._apply_domain_overwrites(url) url = self._apply_domain_overwrites(url)
proxy = self._get_proxy(url, kwargs.pop("proxies", None)) proxy = self._get_proxy(url, kwargs.pop("proxies", None))
merged = CIMultiDict(self._default_headers) merged = {**self._default_headers, **(kwargs.pop("headers", None) or {})}
merged.update(kwargs.pop("headers", None) or {})
headers = self._apply_overwrites(merged) headers = self._apply_overwrites(merged)
timeout = kwargs.get("timeout") timeout = kwargs.get("timeout")
@@ -430,19 +420,11 @@ class ExtendedSession:
kwargs["proxy"] = resolved_proxy kwargs["proxy"] = resolved_proxy
debug = kwargs.pop("debug", False) debug = kwargs.pop("debug", False)
merged = CIMultiDict(self._default_headers) merged = {**self._default_headers, **(kwargs.get("headers") or {})}
merged.update(kwargs.get("headers") or {})
kwargs["headers"] = self._apply_overwrites(merged) kwargs["headers"] = self._apply_overwrites(merged)
kwargs["headers"] = {str(k): str(v) for k, v in kwargs["headers"].items()} kwargs["headers"] = {str(k): str(v) for k, v in kwargs["headers"].items()}
timeout = kwargs.get("timeout") timeout = kwargs.get("timeout")
# the hang-guard deadline: a per-call timeout (a number, or a ClientTimeout's
# .total), else the session default. a timeout=None (omitted, or the explicit None
# request_with_retries passes) means "use the session bound" - so it guards on
# self._session_timeout, NOT unguarded. unguarded only when the caller asked for no
# bound: a session constructed with timeout=None (the documented long-poll/streaming
# opt-out), or a per-call ClientTimeout(total=None).
guard_secs = self._session_timeout if timeout is None else getattr(timeout, "total", timeout)
if isinstance(timeout, (int, float)): if isinstance(timeout, (int, float)):
kwargs["timeout"] = aiohttp.ClientTimeout(total=timeout) kwargs["timeout"] = aiohttp.ClientTimeout(total=timeout)
elif timeout is None and "timeout" in kwargs: elif timeout is None and "timeout" in kwargs:
@@ -456,32 +438,18 @@ class ExtendedSession:
log.info("sending request to: %s", url) log.info("sending request to: %s", url)
try: try:
# hang-guard: an outer asyncio deadline (session/curl timers can die in the
# same failure that parks the transfer - a backend that loses both wakeup and
# its own timeout enforcement would otherwise leave this await parked forever).
# the backend's own timeout still fires first in every healthy failure (slack,
# not replacement); wait_for only trips when the backend's enforcement is dead,
# and its cancellation is what unwedges the orphaned transfer.
if guard_secs is not None:
result = await asyncio.wait_for(
self._raw_request(method, url, **kwargs), guard_secs + _HANG_GUARD_SLACK
)
else:
result = await self._raw_request(method, url, **kwargs) result = await self._raw_request(method, url, **kwargs)
if debug and result.redirect_chain: if debug and result.redirect_chain:
log.info("redirect chain: %s", result.redirect_chain) log.info("redirect chain: %s", result.redirect_chain)
return result return result
except asyncio.TimeoutError as error: except asyncio.TimeoutError as error:
# not an aiohttp.ClientError subclass - wrap as ServerTimeoutError so callers # not an aiohttp.ClientError subclass - wrap as ServerTimeoutError so
# get a typed failure and request_with_retries can label it a timeout. now also # callers get a typed failure and request_with_retries can label it a timeout
# catches the hang-guard firing (asyncio.wait_for raises asyncio.TimeoutError).
raise aiohttp.ServerTimeoutError(f"timeout for {url}: {error}") from error raise aiohttp.ServerTimeoutError(f"timeout for {url}: {error}") from error
except aiohttp.ClientError: except aiohttp.ClientError as error:
# re-raise the original subtype (not flattened) - request_with_retries still # re-raise the original subtype (not flattened) - request_with_retries still
# catches the base aiohttp.ClientError below and is unaffected. no log here: # catches the base aiohttp.ClientError below and is unaffected
# this path RAISES, so the exception carries the failure (raise XOR log); on the log.error("client error for %s: %s", url, error)
# retrying path commons.aretry emits the per-attempt WARNING and the terminal
# branch logs on exhaustion - logging here would double-report the same failure.
raise raise
async def request_with_retries( async def request_with_retries(
@@ -515,11 +483,7 @@ class ExtendedSession:
headers=headers, proxies=proxies, timeout=timeout, debug=debug, headers=headers, proxies=proxies, timeout=timeout, debug=debug,
) )
if response.status_code in retry_statuses: if response.status_code in retry_statuses:
# debug, not warning: this RAISES _RetryStatus to trigger a retry, so it must log.warning("retryable status %s for %s", response.status_code, url)
# not also warn/error the same failure (raise XOR log). commons.aretry emits
# the per-attempt WARNING with the attempt count when it catches and retries;
# the terminal branch below logs on exhaustion.
log.debug("retryable status %s for %s", response.status_code, url)
raise _RetryStatus(response) raise _RetryStatus(response)
return response return response
@@ -528,25 +492,19 @@ class ExtendedSession:
attempt, attempts=attempts, backoff=1.0, factor=backoff_base, attempt, attempts=attempts, backoff=1.0, factor=backoff_base,
jitter=False, on=(Exception,), jitter=False, on=(Exception,),
) )
# terminal path: retries are exhausted and the failure is SWALLOWED into a falsy
# FailureResponse the caller branches on (the documented contract). rule 2 mandates a
# log on a swallow; level is WARNING because the lib recovered cleanly into a
# branchable value - the failing op is the caller's to escalate once it sees the falsy
# result, so the lib does not claim ERROR on the caller's behalf. all four branches are
# the same event class (exhausted retries) and stay at one uniform level.
except _RetryStatus as exhausted: except _RetryStatus as exhausted:
log.warning("all %d attempts failed for %s (last status %s)", log.error("all %d attempts failed for %s (last status %s)",
attempts, url, exhausted.response.status_code) attempts, url, exhausted.response.status_code)
return exhausted.response return exhausted.response
except asyncio.TimeoutError: except asyncio.TimeoutError:
# catch before ClientError so a timeout is labeled as such, not generic # catch before ClientError so a timeout is labeled as such, not generic
log.warning("all %d attempts timed out for %s", attempts, url) log.error("all %d attempts timed out for %s", attempts, url)
return FailureResponse(reason="timeout", url=url) return FailureResponse(reason="timeout", url=url)
except aiohttp.ClientError as error: except aiohttp.ClientError as error:
log.warning("all %d attempts failed for %s (client error: %s)", attempts, url, error) log.error("all %d attempts failed for %s (client error: %s)", attempts, url, error)
return FailureResponse(reason=f"client error: {error}", url=url) return FailureResponse(reason=f"client error: {error}", url=url)
except Exception as error: except Exception as error:
log.warning("all %d attempts failed for %s (unexpected: %s)", attempts, url, error) log.error("all %d attempts failed for %s (unexpected: %s)", attempts, url, error)
return FailureResponse(reason=f"unexpected error: {error}", url=url) return FailureResponse(reason=f"unexpected error: {error}", url=url)
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------