4 Commits
Author SHA1 Message Date
dsql 987c4bdf9c chore: bump to 1.3.0
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-10-16 04:00:40 -04:00
dsql 250c9cf2eb fix: copy config dicts, case-insensitive scheme/headers, guard honors ClientTimeout
T1-4: proxies/overwrites/domain_overwrites were aliased, so clear_proxies()
on one session emptied a sibling built from the same config dict, and the
proxy lookup used the raw scheme so HTTP:// missed the http key. both sent
the request direct. copy the dicts; resolve the scheme via yarl (lowercase).

T2-7: the hang-guard ignored a per-call ClientTimeout and killed the call at
the session timeout + slack. read .total off it.

T2-8: header names merged and overwritten case-sensitively, so curl_cffi
sent both User-Agent and user-agent (400 on strict servers). merge through
CIMultiDict and match overwrites case-insensitively.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-10-16 04:00:33 -04:00
dsql b009c0cf50 chore: bump to 1.2.0 (logging-discipline audit)
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-08-10 23:00:50 -04:00
dsql e6eddf5bc3 fix: logging discipline in request()/request_with_retries
- delete the per-attempt log.error before raise in request()'s ClientError branch: the
  path re-raises (raise XOR log), commons.aretry already emits the per-attempt WARNING
  with the attempt count, and the terminal branch logs on exhaustion - the line was pure
  duplicate reporting of a retried failure.
- demote the retryable-status per-attempt line to debug for the same reason (it raises
  _RetryStatus; aretry owns the retry WARNING).
- level the four terminal branches of request_with_retries from ERROR to WARNING: they
  SWALLOW exhaustion into a falsy FailureResponse the caller branches on (recovered
  cleanly per the swallow contract), so the lib does not claim ERROR on the caller's
  behalf; the caller escalates on the falsy result. all four stay one uniform level.

verified: the terminal branch still fires on genuine exhaustion, request_with_retries
still returns a falsy FailureResponse, and the deleted per-attempt ERROR no longer emits.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-08-09 02:08:58 -04:00
3 changed files with 52 additions and 34 deletions

No files matched your search

+3 -3
View File
@@ -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.1.0
aioweb @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioweb.git@v1.3.0
```
Direct:
```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@v1.3.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.1.0` suffix from the line above to install the latest unpinned.
Drop the `@v1.3.0` suffix from the line above to install the latest unpinned.
## Usage
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "aioweb"
version = "1.1.0"
version = "1.3.0"
description = "Async HTTP session wrapper over aiohttp — proxies, header overwrites, retries, previews. Config-free, installable."
requires-python = ">=3.10"
dependencies = [
+48 -30
View File
@@ -15,6 +15,7 @@ import warnings
from http.cookies import SimpleCookie
import aiohttp
from multidict import CIMultiDict
from yarl import URL
from commons import aretry
@@ -85,12 +86,15 @@ class ExtendedSession:
kwargs: passed through to aiohttp.ClientSession
"""
self.inject = inject
self.header_overwrites = overwrites or {}
self.domain_overwrites = domain_overwrites or {}
# copied, not aliased: a shared config dict must not be mutated by (or leak a
# clear_proxies() into) a sibling session
self.header_overwrites = dict(overwrites or {})
self.domain_overwrites = dict(domain_overwrites or {})
self.ephemeral_headers = {}
self.proxies = proxies or {}
# own header layer, not aiohttp privates, so update_headers/clear_headers work
self._default_headers = dict(headers or {})
self.proxies = dict(proxies or {})
# own header layer, not aiohttp privates, so update_headers/clear_headers work;
# case-insensitive like HTTP header names
self._default_headers = CIMultiDict(headers or {})
self._session_timeout = timeout
self._session_kwargs = kwargs
# aiohttp>=3.14 needs a running loop to build ClientSession; built lazily instead
@@ -139,8 +143,12 @@ class ExtendedSession:
self.header_overwrites[header] = value
def _apply_overwrites(self, request_headers):
"""apply static overwrites and ephemeral headers to a request's headers"""
request_headers = dict(request_headers or {})
"""apply static overwrites and ephemeral headers to a request's headers
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
# "dict changed size during iteration"
for header, value in list(self.header_overwrites.items()):
@@ -153,7 +161,7 @@ class ExtendedSession:
request_headers.update(value)
else:
request_headers[header] = value
return request_headers
return dict(request_headers)
def set_ephemeral(self, header, value_callable):
"""register a header generated fresh (via callable) for each request"""
@@ -262,7 +270,7 @@ class ExtendedSession:
falling back to them.
"""
proxies = self.proxies if proxies is None else proxies
scheme = url.split("://")[0]
scheme = URL(url).scheme
return proxies.get(scheme)
# -------------------------------------------------------------------------
@@ -353,7 +361,8 @@ class ExtendedSession:
"""
url = self._apply_domain_overwrites(url)
proxy = self._get_proxy(url, kwargs.pop("proxies", None))
merged = {**self._default_headers, **(kwargs.pop("headers", None) or {})}
merged = CIMultiDict(self._default_headers)
merged.update(kwargs.pop("headers", None) or {})
headers = self._apply_overwrites(merged)
timeout = kwargs.get("timeout")
@@ -421,22 +430,19 @@ class ExtendedSession:
kwargs["proxy"] = resolved_proxy
debug = kwargs.pop("debug", False)
merged = {**self._default_headers, **(kwargs.get("headers") or {})}
merged = CIMultiDict(self._default_headers)
merged.update(kwargs.get("headers") or {})
kwargs["headers"] = self._apply_overwrites(merged)
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
# 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)):
kwargs["timeout"] = aiohttp.ClientTimeout(total=timeout)
elif timeout is None and "timeout" in kwargs:
@@ -470,10 +476,12 @@ class ExtendedSession:
# 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:
except aiohttp.ClientError:
# re-raise the original subtype (not flattened) - request_with_retries still
# catches the base aiohttp.ClientError below and is unaffected
log.error("client error for %s: %s", url, error)
# catches the base aiohttp.ClientError below and is unaffected. no log here:
# this path RAISES, so the exception carries the failure (raise XOR log); on the
# 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
async def request_with_retries(
@@ -507,7 +515,11 @@ class ExtendedSession:
headers=headers, proxies=proxies, timeout=timeout, debug=debug,
)
if response.status_code in retry_statuses:
log.warning("retryable status %s for %s", response.status_code, url)
# debug, not warning: this RAISES _RetryStatus to trigger a retry, so it must
# 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)
return response
@@ -516,19 +528,25 @@ class ExtendedSession:
attempt, attempts=attempts, backoff=1.0, factor=backoff_base,
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:
log.error("all %d attempts failed for %s (last status %s)",
log.warning("all %d attempts failed for %s (last status %s)",
attempts, url, exhausted.response.status_code)
return exhausted.response
except asyncio.TimeoutError:
# catch before ClientError so a timeout is labeled as such, not generic
log.error("all %d attempts timed out for %s", attempts, url)
log.warning("all %d attempts timed out for %s", attempts, url)
return FailureResponse(reason="timeout", url=url)
except aiohttp.ClientError as error:
log.error("all %d attempts failed for %s (client error: %s)", attempts, url, error)
log.warning("all %d attempts failed for %s (client error: %s)", attempts, url, error)
return FailureResponse(reason=f"client error: {error}", url=url)
except Exception as error:
log.error("all %d attempts failed for %s (unexpected: %s)", attempts, url, error)
log.warning("all %d attempts failed for %s (unexpected: %s)", attempts, url, error)
return FailureResponse(reason=f"unexpected error: {error}", url=url)
# -------------------------------------------------------------------------