15 Commits
Author SHA1 Message Date
dsql 0768d643b1 fix: preserve exception subtype in request(), render params/timeout in as_curl()
request() re-raised every aiohttp.ClientError subclass as the bare base class,
losing ClientConnectorError/ClientProxyConnectionError/ClientResponseError
(.status/.headers)/TooManyRedirects and their attributes; direct callers
branching by type never matched. Now the original exception is bare-raised,
preserving subtype, attributes, and __cause__. request_with_retries (which
catches the base ClientError) is unaffected.

as_curl() silently dropped params and timeout, so a debug=True replay of a
params-driven request hit a different URL than the one actually sent. params
are now merged into the URL via yarl before quoting, and timeout renders as
--max-time.

Bumps 0.1.7 -> 0.1.8.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-02 16:57:04 -04:00
dsql e1ab5d38a0 fix: cookie methods actually work; session builds lazily (v0.1.7)
get_cookies() called filter_cookies() with no URL and always returned {}
for domain-bound cookies; now iterates the jar directly. set_cookie()
ignored path and leaked a shared cookie to every host when given a bare
domain string; scheme is now normalized and path honored, with
domain=None made an intentionally shared cookie instead of a silent
localhost-only no-op.

ExtendedSession also built its aiohttp.ClientSession synchronously in
__init__, which requires a running event loop under aiohttp>=3.14 and
crashed the common construct-before-the-loop-starts host pattern. The
session now builds lazily on first access, preserving the
_create_session subclass override seam used by aioweb_tls.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-02 16:42:30 -04:00
dsql b8cd184c64 fix: request_with_retries honors session timeout (timeout=None no longer disables it)
request() forwarded an explicit timeout=None to aiohttp as ClientTimeout(total=None),
which disables the timeout and overrides the session default. request_with_retries
defaults timeout=None, so its flagship path had zero timeout protection: a hung server
stalled the coroutine forever, pinning connector-pool slots. Pop a None timeout in
request() so the session-level timeout applies; numeric per-call timeouts still wrap.

aioweb-1 (v0.1.6).

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-02 16:33:28 -04:00
dsql 74ed83cf73 chore: ignore .claude/ dir (CLAUDE.md now lives under .claude/)
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 21:55:13 -04:00
dsql 14a3ee1456 fix: AW-2 json() returns None on a non-UTF-8 body instead of raising
responses.json() catches UnicodeDecodeError alongside JSONDecodeError — text() can raise
it on a non-UTF-8 payload, which is a 'not valid JSON' outcome per the docstring, not an
error to propagate.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 21:34:37 -04:00
dsql 3737af0cf5 fix: total-timeout labeled 'timeout' in request_with_retries (dead branch live) (v0.1.5)
AW-1: request() wraps a total ClientTimeout's bare asyncio.TimeoutError before
request_with_retries sees it, so the dedicated 'timeout' branch was dead and its comment
lied. wrap it as aiohttp.ServerTimeoutError (which IS both a ClientError AND a
TimeoutError) so direct request() callers still get a typed failure (M1 preserved) while
request_with_retries catches the timeout case first and labels it 'timeout'.

verified by execution: request() raises ServerTimeoutError (typed, M1 intact);
request_with_retries returns reason='timeout'; control confirms a real client error still
labels 'client error'. sibling-grep: aioweb_tls/aiowebhooks catch ClientError/TimeoutError,
both of which ServerTimeoutError satisfies — no consumer break.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 20:47:55 -04:00
dsql d3f2bed7fe docs: pin install line to release, note unpinned-latest option
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 18:13:34 -04:00
dsql 849200985c docs: show unpinned install line; note tag-pinning for reproducibility
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 18:07:19 -04:00
dsql 7da06443c8 fix: label timeouts, render empty-but-valid preview bodies, snapshot override dicts (v0.1.4)
- request_with_retries labels an exhausted total-timeout as 'timeout' instead of the
  generic 'unexpected error' catch-all (nit)
- as_curl() renders an empty-but-valid json body ({} / []) via is-not-None instead of
  dropping it as falsy (nit)
- _apply_overwrites snapshots the shared override dicts before iterating, so a
  concurrent mutation can't raise 'dict changed size during iteration' (nit).

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 17:57:54 -04:00
dsql 382b8aa632 fix: request() wraps bare asyncio.TimeoutError on total-timeout (v0.1.3)
broaden the except to (aiohttp.ClientError, asyncio.TimeoutError) and re-wrap into
the same typed aiohttp.ClientError path. a total ClientTimeout raises a bare
asyncio.TimeoutError, which is NOT an aiohttp.ClientError subclass, so it previously
leaked raw out of request()/test_proxies(). add the missing asyncio import.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 17:09:20 -04:00
dsql d527174a2b fix: make session-default headers fully mutable; preview matches request in all cases
headers passed at construction were baked into aiohttp's ClientSession(headers=) (an immutable per-session map) AND merged in request(), a double path that made clear_headers()/update_headers() unable to remove or change what reached the wire. dropped headers= from the aiohttp session so _default_headers is our sole, mutable layer that request() and preview() both merge (defaults -> per-request -> overwrites). preview() now merges identically to request() even when explicit per-request headers are passed (it previously dropped session defaults in that case), so preview == wire in every case. clear_headers clears our defaults (not zero headers — per-request + overwrites still flow).

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 01:10:25 -04:00
dsql bad3ea2677 fix: merge session-default headers into request() so the header API works
request() built outgoing headers from per-request kwargs only and never read self._default_headers, so update_headers()/clear_headers() mutated a field that never reached the wire — while preview() DID read it, so preview diverged from the real send. request() now merges _default_headers (defaults -> per-request -> overwrites), making the session-default header API functional and preview consistent with request.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-28 18:45:25 -04:00
dsql dc3fb70a1e fix: shlex.quote values in as_curl() so the command is valid and not injectable
header/body/url/proxy values were wrapped in raw single quotes, so a value containing a quote or shell metacharacter produced a broken or injectable command. every interpolated value is now shell-quoted.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-28 17:18:28 -04:00
dsql 7a2f24be9e chore: pin commons v0.2.1 (v0.1.2)
bump the commons dependency pin to v0.2.1 (retry attempts-floor fix). no code change;
the aretry migration is unaffected.

verified: 18/18 migration harness passes against commons 0.2.1.
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-28 16:17:38 -04:00
dsql 7779d0b050 fix: list JSON body + preserve real last response; retry via commons.aretry (v0.1.1)
- #7: request_with_retries routed only dicts to json=, so a valid JSON list body
  was form-encoded via data=. add _route_body so dict OR list -> json=.
- #6: when every attempt returned a retryable status, the loop discarded the real
  response and returned a synthetic FailureResponse (status 0). now the real last
  4xx/5xx Response is returned on exhaustion (only a pure-exception failure yields
  FailureResponse).
- migrate the retry/backoff loop onto commons.aretry (>=0.2.0); backoff schedule
  unchanged (1,2,... = backoff_base**n), jitter off to match prior behavior.

verified by execution: list->json routing, exhausted 503 returns real 503 + body
with correct backoff, success/404 immediate, exception->falsy FailureResponse.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-27 21:47:49 -04:00
6 changed files with 286 additions and 64 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
# claude
CLAUDE.md
.claude/
# python
__pycache__/
+75 -4
View File
@@ -11,17 +11,19 @@ and swap the HTTP client while inheriting everything else.
`requirements.txt`:
```
aioweb @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioweb.git@v0.1.0
aioweb @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioweb.git@v0.1.8
```
Direct:
```bash
pip install "aioweb @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioweb.git@v0.1.0"
pip install "aioweb @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioweb.git@v0.1.8"
```
Requires `aiohttp` and `yarl` (pulled transitively).
Drop the `@v0.1.8` suffix from the line above to install the latest unpinned.
## Usage
```python
@@ -63,6 +65,15 @@ resp = await s.request_with_retries(
Returns a `FailureResponse` (falsy) if every attempt fails.
`request()` (the non-retrying call) raises on failure: a total timeout raises
`aiohttp.ServerTimeoutError`, and any other network/protocol failure raises its real
`aiohttp.ClientError` subtype as-is (`ClientConnectorError`, `ClientProxyConnectionError`,
`ClientResponseError` with `.status`/`.headers`, `TooManyRedirects`, ...) — it is not
flattened into the base `ClientError`, so direct callers can branch by type or read
subtype attributes. `request_with_retries` catches the base `aiohttp.ClientError` (and
`asyncio.TimeoutError`) across all attempts and returns a falsy `FailureResponse` instead
of raising.
## Header overwrites & ephemeral headers
```python
@@ -86,7 +97,10 @@ s.overwrite_domain("internal.local", "127.0.0.1") # host-substring rewrite
print(s.preview("POST", url, json={"a": 1}).as_curl()) # equivalent cURL command
```
Pass `debug=True` to `request_with_retries` to log the cURL preview and request flow.
`as_curl()` renders `params` (merged into the url's query string) and `timeout` (as
`--max-time`) as well as headers/body/proxy, so the emitted command is faithful to what
`request()` actually sends. Pass `debug=True` to `request_with_retries` to log the cURL
preview and request flow.
## Custom backends
@@ -128,6 +142,63 @@ Two changes can't be shimmed without re-introducing the bugs they fix:
`await s.close()`; a leaked session emits a `ResourceWarning`. The old finalizer-based
auto-close was unsafe and was removed.
## Changelog
### v0.1.8
- **`request()` no longer flattens `aiohttp.ClientError` subtypes.** Every failure
(connect errors, proxy errors, `raise_for_status()`-style response errors,
redirect limits, ...) was re-raised as a bare `aiohttp.ClientError`, losing the
real subtype and its attributes (`.os_error`, `.status`, `.headers`, ...) — a
direct caller doing `except ClientProxyConnectionError:` or `if e.status == 401`
would silently never match. Now the original exception is re-raised as-is (its
subtype, attributes, and `__cause__` all preserved). `request_with_retries`
still catches the base `aiohttp.ClientError` across attempts, so its behavior
(and its `FailureResponse` return on exhaustion) is unchanged.
- **`as_curl()` now renders `params` and `timeout`.** Previously a preview built
with `params=` silently omitted the query string (and a `timeout=` omitted
`--max-time`), so a `debug=True` cURL replay of a params-driven request hit a
different URL than the one actually sent. `params` are now merged into the url's
query string (via `yarl`) and `timeout` is emitted as `--max-time`.
### v0.1.7
- **`get_cookies()` now returns real cookies.** Previously called `filter_cookies()`
with no URL, which only ever returns domain-less shared cookies — every normal
domain-bound cookie (including ones set by a real `Set-Cookie` response) was
silently omitted. Now iterates the jar directly.
- **`set_cookie()` no longer leaks a shared cookie to every host.** A bare hostname
(`domain="example.com"`) built a schemeless URL, which aiohttp's jar treats as a
domain-less "shared" cookie sent with every request the session makes, including
unrelated hosts. A scheme is now added when missing so the cookie is scoped to
that host.
- **`set_cookie()` now honors `path`** (previously ignored — the cookie always
landed at `path="/"`). `domain=None` is unchanged in meaning but now stores a
truly shared cookie (sent to every host) instead of one silently bound to
`localhost` only, which made `set_cookie(name, value)` (no domain) a silent
no-op for any real request.
- **The backend session is built lazily**, not in `__init__`. Under aiohttp 3.14,
constructing `aiohttp.ClientSession` requires a running event loop; eager
construction crashed the common host pattern of attaching a session before the
loop starts (e.g. `bot.http = ExtendedSession(...)` in `Bot.__init__`). The
session (and any subclass's `_create_session` override) now builds on first
access instead.
### v0.1.2
- Pinned `commons` to v0.2.1 (retry `attempts` floor fix).
### v0.1.1
- **JSON list bodies** now route to `json=` (were wrongly form-encoded via `data=`
only dicts went to `json=` before).
- **Exhausted retries return the real last response.** When every attempt hit a
retryable status (429/5xx), the loop discarded it and returned a synthetic
`FailureResponse` (status 0); now the real last 4xx/5xx `Response` is returned (only a
pure-exception failure yields `FailureResponse`).
- Retry/backoff moved onto `commons.aretry` (shared engine); backoff schedule unchanged.
Adds a `commons` dependency.
## Versioning
Tagged `vX.Y.Z`. Pin the tag in `requirements.txt`.
Releases are tagged `vX.Y.Z`. The install line above pins a release; drop the `@vX.Y.Z` suffix to install the latest unpinned. Pin deliberately for reproducible installs.
+5 -1
View File
@@ -4,13 +4,17 @@ build-backend = "hatchling.build"
[project]
name = "aioweb"
version = "0.1.0"
version = "0.1.8"
description = "Async HTTP session wrapper over aiohttp — proxies, header overwrites, retries, previews. Config-free, installable."
requires-python = ">=3.10"
dependencies = [
"aiohttp>=3.9",
"yarl>=1.9",
"commons @ git+ssh://git@git.rethinkstudios.io/rethink-public/commons.git@v0.2.1",
]
[tool.hatch.metadata]
allow-direct-references = true
[tool.hatch.build.targets.wheel]
packages = ["src/aioweb"]
+27 -9
View File
@@ -3,6 +3,9 @@ request preview for aioweb — format or export a request without sending it
"""
import json as _json
import shlex
from yarl import URL
class RequestPreview:
@@ -25,15 +28,30 @@ class RequestPreview:
return "\n".join(f"{key}: {value}" for key, value in self.details.items())
def as_curl(self):
"""equivalent cURL command for the request"""
parts = [f"curl -X {self.details['method']}"]
"""equivalent cURL command for the request
every interpolated value is shell-quoted with shlex.quote, so headers,
body, url, or proxy containing quotes/spaces/metacharacters produce a
valid, non-injectable command rather than a broken or unsafe one. `params`
is merged into the url's query string (via yarl) and `timeout` is rendered
as `--max-time`, so the emitted command is faithful to what request()
actually sends rather than dropping either silently.
"""
parts = [f"curl -X {shlex.quote(self.details['method'])}"]
for header, value in (self.details["headers"] or {}).items():
parts.append(f"-H '{header}: {value}'")
if self.details["data"]:
parts.append(f"--data '{self.details['data']}'")
elif self.details["json"]:
parts.append(f"--data '{_json.dumps(self.details['json'])}'")
parts.append(f"'{self.details['url']}'")
parts.append(f"-H {shlex.quote(f'{header}: {value}')}")
if self.details["data"] is not None:
parts.append(f"--data {shlex.quote(str(self.details['data']))}")
elif self.details["json"] is not None:
# is-not-None, not truthiness: an empty-but-valid body ({} / []) must still
# render rather than being dropped as falsy
parts.append(f"--data {shlex.quote(_json.dumps(self.details['json']))}")
url = self.details["url"]
if self.details["params"]:
url = str(URL(url).update_query(self.details["params"]))
parts.append(shlex.quote(str(url)))
if self.details["proxy"]:
parts.append(f"--proxy '{self.details['proxy']}'")
parts.append(f"--proxy {shlex.quote(str(self.details['proxy']))}")
if self.details["timeout"] is not None:
parts.append(f"--max-time {shlex.quote(str(self.details['timeout']))}")
return " \\\n ".join(parts)
+3 -1
View File
@@ -92,7 +92,9 @@ class Response:
"""parsed JSON content, or None if not valid JSON"""
try:
return _json.loads(self.text())
except _json.JSONDecodeError:
except (_json.JSONDecodeError, UnicodeDecodeError):
# text() decodes the body and can raise UnicodeDecodeError on a non-UTF-8
# payload — that's a "not valid JSON" outcome, not an error to propagate
return None
def raise_for_status(self):
+168 -41
View File
@@ -12,7 +12,10 @@ subclass and override just that one method, inheriting everything else.
if resp: # FailureResponse is falsy
data = resp.json()
config-free: proxies/headers/timeouts are passed at construction or per call.
config-free: proxies/headers/timeouts are passed at construction or per call. the
backend HTTP session is built lazily on first use (request/cookie access/close), not
in __init__, so construction is safe before an event loop is running (e.g. attaching
to a host object at process startup).
sessions must be closed explicitly (async with, or await s.close()); there is no
__del__ auto-close (that pattern is unsafe for async resources).
"""
@@ -20,13 +23,40 @@ __del__ auto-close (that pattern is unsafe for async resources).
import asyncio
import logging
import warnings
from http.cookies import SimpleCookie
import aiohttp
from yarl import URL
from commons import aretry
from .preview import RequestPreview
from .responses import Response, FailureResponse
def _route_body(data):
"""split a body into (data=, json=) kwargs
dict OR list bodies are valid JSON and route to json=; everything else
(str/bytes/form) routes to data=. previously only dicts went to json=, so a
JSON list was wrongly form-encoded.
"""
if isinstance(data, (dict, list)):
return None, data
return data, None
class _RetryStatus(Exception):
"""internal signal: a retryable HTTP status; carries the real Response
raised inside an attempt so commons.aretry drives the backoff + cap; the caller
catches the final one to return the REAL last response, not a synthetic failure.
"""
def __init__(self, response):
super().__init__(f"retryable status {response.status_code}")
self.response = response
log = logging.getLogger(__name__)
DEFAULT_ATTEMPTS = 3
@@ -60,7 +90,27 @@ class ExtendedSession:
self.proxies = proxies or {}
# track our own default headers instead of touching aiohttp privates
self._default_headers = dict(headers or {})
self.session = self._create_session(self._default_headers, timeout, **kwargs)
self._session_timeout = timeout
self._session_kwargs = kwargs
# aiohttp.ClientSession (via _create_session) requires a running event loop
# (aiohttp >= 3.14 raises RuntimeError otherwise); building it here would
# break the common host pattern of constructing before the loop starts
# (e.g. bot.http = ExtendedSession(...) in Bot.__init__). build lazily on
# first access instead, via the `session` property / _ensure_session().
self._session = None
@property
def session(self):
"""the backend session, built lazily on first access (needs a running loop)"""
self._ensure_session()
return self._session
def _ensure_session(self):
"""build the backend session on first use — idempotent"""
if self._session is None:
self._session = self._create_session(
self._default_headers, self._session_timeout, **self._session_kwargs,
)
def _create_session(self, headers, timeout, **kwargs):
"""create the backend HTTP session — override to use a different client
@@ -70,9 +120,15 @@ class ExtendedSession:
proxy/retry/preview logic in this class never touches the session object
directly (only _raw_request, the cookie methods, and close do), so those
features work unchanged on any backend.
`headers` is the session-default header set; the default aiohttp backend
does NOT bake it into the ClientSession (which would copy it into an
immutable per-session map that update_headers/clear_headers can't touch).
instead `_default_headers` is our own mutable layer that request() and
preview() merge per call, so the mutable session-header API actually works.
a backend that needs the defaults baked at construction may use `headers`.
"""
return aiohttp.ClientSession(
headers=headers,
timeout=aiohttp.ClientTimeout(
total=timeout,
connect=timeout / 2,
@@ -92,10 +148,13 @@ class ExtendedSession:
def _apply_overwrites(self, request_headers):
"""apply static overwrites and ephemeral headers to a request's headers"""
request_headers = dict(request_headers or {})
for header, value in self.header_overwrites.items():
# snapshot the shared override dicts so a concurrent mutation (e.g. a command
# editing ephemerals on a shared session) can't raise "dict changed size during
# iteration" — the loop body is sync, but the snapshot is cheap insurance
for header, value in list(self.header_overwrites.items()):
if self.inject or header in request_headers:
request_headers[header] = value
for header, value_callable in self.ephemeral_headers.items():
for header, value_callable in list(self.ephemeral_headers.items()):
if self.inject or header in request_headers:
value = value_callable()
if isinstance(value, dict):
@@ -182,13 +241,36 @@ class ExtendedSession:
# cookies
def get_cookies(self):
"""cookies stored in the session jar"""
return self.session.cookie_jar.filter_cookies()
"""all cookies stored in the session jar, regardless of domain binding
iterates the jar directly rather than filter_cookies() (which needs a url
and, given none, returns only domain-less shared cookies — i.e. {} for any
normal domain-bound cookie).
"""
return {c.key: c.value for c in self.session.cookie_jar}
def set_cookie(self, name, value, domain=None, path="/"):
"""set a cookie in the session jar"""
response_url = URL(domain or "http://localhost")
self.session.cookie_jar.update_cookies({name: value}, response_url=response_url)
"""set a cookie in the session jar
domain=None (the default) stores a truly shared cookie sent with every
request regardless of host — the jar's own "no response_url" behavior.
pass domain='example.com' (a scheme is optional and defaulted to http://)
to scope the cookie to one host; a bare hostname like 'example.com' is
normalized into a URL so the jar binds it by host instead of silently
storing another domain-less shared cookie (a schemeless domain has no
raw_host, so the jar can't tell it apart from the shared case).
`path` is honored via the morsel itself, since the jar only derives a path
from response_url when the morsel doesn't already carry one.
"""
cookie = SimpleCookie()
cookie[name] = value
if domain is None:
self.session.cookie_jar.update_cookies(cookie)
return
if "://" not in domain:
domain = "http://" + domain
cookie[name]["path"] = path
self.session.cookie_jar.update_cookies(cookie, response_url=URL(domain))
def clear_cookies(self):
"""clear the session cookie jar"""
@@ -208,10 +290,8 @@ class ExtendedSession:
def preview(self, method, url, **kwargs):
"""build a RequestPreview for a request without sending it"""
proxy = self._get_proxy(url, kwargs.pop("proxies", None))
if kwargs.get("headers"):
headers = self._apply_overwrites(kwargs.pop("headers"))
else:
headers = dict(self.get_headers())
merged = {**self._default_headers, **(kwargs.pop("headers", None) or {})}
headers = self._apply_overwrites(merged)
timeout = kwargs.get("timeout")
timeout_total = timeout if isinstance(timeout, (int, float)) else None
@@ -262,16 +342,30 @@ class ExtendedSession:
)
async def request(self, method, url, **kwargs) -> Response:
"""make a request, applying overwrites/domain rewrites/proxy resolution"""
"""make a request, applying overwrites/domain rewrites/proxy resolution
raises the real exception subtype on failure: a total timeout raises
aiohttp.ServerTimeoutError, and any other aiohttp.ClientError (e.g.
ClientConnectorError, ClientProxyConnectionError, ClientResponseError with
.status/.headers, TooManyRedirects) is re-raised as-is, not flattened into
the base ClientError — callers can branch by type or read subtype attributes.
"""
kwargs["proxy"] = self._get_proxy(url, kwargs.pop("proxies", None))
debug = kwargs.pop("debug", False)
kwargs["headers"] = self._apply_overwrites(kwargs.get("headers"))
merged = {**self._default_headers, **(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")
if isinstance(timeout, (int, float)):
kwargs["timeout"] = aiohttp.ClientTimeout(total=timeout)
elif timeout is None and "timeout" in kwargs:
# an explicit timeout=None reaches aiohttp as ClientTimeout(total=None),
# which DISABLES the timeout and overrides the session default; drop it so
# the session-level timeout applies (matters for request_with_retries, whose
# timeout kwarg defaults to None)
del kwargs["timeout"]
url = self._apply_domain_overwrites(url)
if debug:
@@ -282,8 +376,20 @@ class ExtendedSession:
if debug and result.redirect_chain:
log.info("redirect chain: %s", result.redirect_chain)
return result
except asyncio.TimeoutError as error:
# a total ClientTimeout raises a bare asyncio.TimeoutError, which is NOT an
# aiohttp.ClientError subclass — wrap it as ServerTimeoutError (which IS both
# a ClientError AND a TimeoutError) so direct callers get a typed failure and
# request_with_retries can still label it a timeout
raise aiohttp.ServerTimeoutError(f"timeout for {url}: {error}") from error
except aiohttp.ClientError as error:
raise aiohttp.ClientError(f"client error for {url}: {error}") from error
# re-raise the ORIGINAL subtype (ClientConnectorError, ClientProxyConnectionError,
# ClientResponseError with .status/.headers, TooManyRedirects, ...) instead of
# flattening into the base class — direct callers branching by type or reading
# subtype attributes need the real exception; request_with_retries still catches
# the base aiohttp.ClientError below and is unaffected
log.error("client error for %s: %s", url, error)
raise
async def request_with_retries(
self, method, url, *, data=None, proxies=None, timeout=None, attempts=None,
@@ -295,55 +401,76 @@ class ExtendedSession:
returns a Response on success (or non-retryable status), or a falsy
FailureResponse if every attempt fails. backoff is exponential
(backoff_base ** attempt).
timeout defaults to None, which falls back to the session-level timeout set
at construction (aioweb pops a None timeout so it does not reach aiohttp as an
infinite ClientTimeout); pass a number to override per call.
"""
attempts = attempts or DEFAULT_ATTEMPTS
last_error = None
body_data, body_json = _route_body(data)
if debug:
preview = self.preview(
method=method, url=url, params=params,
data=None if isinstance(data, dict) else data,
json=data if isinstance(data, dict) else None,
data=body_data, json=body_json,
headers=headers, proxies=proxies, timeout=timeout,
).as_curl()
log.info("[aioweb.debug]\n%s\nproxies: %s inject: %s", preview, self.proxies, self.inject)
for attempt in range(attempts):
try:
async def attempt():
response = await self.request(
method=method, url=url, params=params,
data=None if isinstance(data, dict) else data,
json=data if isinstance(data, dict) else None,
data=body_data, json=body_json,
headers=headers, proxies=proxies, timeout=timeout, debug=debug,
)
if response.status_code in retry_statuses:
last_error = f"retryable status {response.status_code}"
log.warning("attempt %d: %s for %s", attempt + 1, last_error, url)
else:
log.warning("retryable status %s for %s", response.status_code, url)
raise _RetryStatus(response)
return response
try:
return await aretry(
attempt, attempts=attempts, backoff=1.0, factor=backoff_base,
jitter=False, on=(Exception,),
)
except _RetryStatus as exhausted:
log.error("all %d attempts failed for %s (last status %s)",
attempts, url, exhausted.response.status_code)
return exhausted.response
except asyncio.TimeoutError:
# request() wraps a total timeout as ServerTimeoutError (a ClientError AND a
# TimeoutError); catch the timeout case first so it's labeled a timeout rather
# than falling into the generic client-error branch below
log.error("all %d attempts timed out for %s", attempts, url)
return FailureResponse(reason="timeout", url=url)
except aiohttp.ClientError as error:
last_error = f"client error: {error}"
log.warning("attempt %d: %s, retrying", attempt + 1, last_error)
log.error("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:
last_error = f"unexpected error: {error}"
log.exception("attempt %d: %s, retrying", attempt + 1, last_error)
if attempt < attempts - 1:
await asyncio.sleep(backoff_base ** attempt)
log.error("all %d attempts failed for %s", attempts, url)
return FailureResponse(reason=last_error, url=url)
log.error("all %d attempts failed for %s (unexpected: %s)", attempts, url, error)
return FailureResponse(reason=f"unexpected error: {error}", url=url)
# -------------------------------------------------------------------------
# lifecycle
async def close(self):
"""close the backend session — override if the backend's close differs"""
await self.session.close()
"""close the backend session — override if the backend's close differs
a no-op if the session was never built (lazy construction means a session
that made no request and was never otherwise touched has nothing to close).
"""
if self._session is not None:
await self._session.close()
def _is_closed(self) -> bool:
"""whether the backend session is closed — override for non-aiohttp backends"""
return self.session.closed
"""whether the backend session is closed — override for non-aiohttp backends
an unbuilt (never-lazily-created) session counts as closed: nothing was
opened, so there is nothing to leak and __del__ should not warn.
"""
if self._session is None:
return True
return self._session.closed
async def __aenter__(self):
return self