Compare commits
1
Commits
v1.2.0
..
a28ebfc593
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a28ebfc593 |
@@ -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.1.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.1.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.1.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
@@ -4,13 +4,13 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "aioweb"
|
name = "aioweb"
|
||||||
version = "1.2.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]
|
||||||
|
|||||||
+12
-48
@@ -63,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:
|
||||||
@@ -426,17 +425,6 @@ class ExtendedSession:
|
|||||||
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, 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)):
|
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:
|
||||||
@@ -450,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
|
result = await self._raw_request(method, url, **kwargs)
|
||||||
# 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:
|
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(
|
||||||
@@ -509,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
|
||||||
|
|
||||||
@@ -522,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)
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user