diff --git a/README.md b/README.md index 03f3f96..f28af49 100644 --- a/README.md +++ b/README.md @@ -11,19 +11,19 @@ and swap the HTTP client while inheriting everything else. `requirements.txt`: ``` -aioweb @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioweb.git@v1.0.2 +aioweb @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioweb.git@v1.1.0 ``` Direct: ```bash -pip install "aioweb @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioweb.git@v1.0.2" +pip install "aioweb @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioweb.git@v1.1.0" ``` 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). -Drop the `@v1.0.2` suffix from the line above to install the latest unpinned. +Drop the `@v1.1.0` suffix from the line above to install the latest unpinned. ## Usage @@ -150,6 +150,22 @@ Two changes can't be shimmed without re-introducing the bugs they fix: ## 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 - **Session-default timeout no longer poisons pooled keep-alive connections.** diff --git a/pyproject.toml b/pyproject.toml index bce33d7..b376e14 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "aioweb" -version = "1.0.2" +version = "1.1.0" description = "Async HTTP session wrapper over aiohttp — proxies, header overwrites, retries, previews. Config-free, installable." requires-python = ">=3.10" dependencies = [ diff --git a/src/aioweb/session.py b/src/aioweb/session.py index abea816..a503696 100644 --- a/src/aioweb/session.py +++ b/src/aioweb/session.py @@ -63,6 +63,7 @@ log = logging.getLogger(__name__) DEFAULT_ATTEMPTS = 3 DEFAULT_BACKOFF_BASE = 2.0 RETRY_STATUSES = frozenset({429, 500, 502, 503, 504}) +_HANG_GUARD_SLACK = 5 class ExtendedSession: @@ -425,6 +426,17 @@ class ExtendedSession: kwargs["headers"] = {str(k): str(v) for k, v in kwargs["headers"].items()} timeout = kwargs.get("timeout") + # the hang-guard deadline, captured as a bare number BEFORE the ClientTimeout + # conversion below consumes it: a per-call numeric timeout, 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. the ONLY unguarded case is a session deliberately constructed + # unbounded (Session(timeout=None) -> self._session_timeout is None): the + # documented opt-out for a genuinely unbounded call (long-poll/streaming). + if isinstance(timeout, (int, float)): + guard_secs = timeout + else: + guard_secs = self._session_timeout if isinstance(timeout, (int, float)): kwargs["timeout"] = aiohttp.ClientTimeout(total=timeout) elif timeout is None and "timeout" in kwargs: @@ -438,13 +450,25 @@ class ExtendedSession: log.info("sending request to: %s", url) try: - result = await self._raw_request(method, url, **kwargs) + # 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) if debug and result.redirect_chain: log.info("redirect chain: %s", result.redirect_chain) return result except asyncio.TimeoutError as error: - # not an aiohttp.ClientError subclass - wrap as ServerTimeoutError so - # callers get a typed failure and request_with_retries can label it a timeout + # not an aiohttp.ClientError subclass - wrap as ServerTimeoutError so callers + # get a typed failure and request_with_retries can label it a timeout. now also + # catches the hang-guard firing (asyncio.wait_for raises asyncio.TimeoutError). raise aiohttp.ServerTimeoutError(f"timeout for {url}: {error}") from error except aiohttp.ClientError as error: # re-raise the original subtype (not flattened) - request_with_retries still