6 Commits
Author SHA1 Message Date
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
4 changed files with 105 additions and 44 deletions
+19 -2
View File
@@ -11,13 +11,13 @@ and swap the HTTP client while inheriting everything else.
`requirements.txt`: `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.3
``` ```
Direct: Direct:
```bash ```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.3"
``` ```
Requires `aiohttp` and `yarl` (pulled transitively). Requires `aiohttp` and `yarl` (pulled transitively).
@@ -128,6 +128,23 @@ 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 `await s.close()`; a leaked session emits a `ResourceWarning`. The old finalizer-based
auto-close was unsafe and was removed. auto-close was unsafe and was removed.
## Changelog
### 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 ## Versioning
Tagged `vX.Y.Z`. Pin the tag in `requirements.txt`. Tagged `vX.Y.Z`. Pin the tag in `requirements.txt`.
+5 -1
View File
@@ -4,13 +4,17 @@ build-backend = "hatchling.build"
[project] [project]
name = "aioweb" name = "aioweb"
version = "0.1.0" version = "0.1.3"
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+ssh://git@git.rethinkstudios.io/rethink-public/commons.git@v0.2.1",
] ]
[tool.hatch.metadata]
allow-direct-references = true
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["src/aioweb"] packages = ["src/aioweb"]
+13 -7
View File
@@ -3,6 +3,7 @@ request preview for aioweb — format or export a request without sending it
""" """
import json as _json import json as _json
import shlex
class RequestPreview: class RequestPreview:
@@ -25,15 +26,20 @@ class RequestPreview:
return "\n".join(f"{key}: {value}" for key, value in self.details.items()) return "\n".join(f"{key}: {value}" for key, value in self.details.items())
def as_curl(self): def as_curl(self):
"""equivalent cURL command for the request""" """equivalent cURL command for the request
parts = [f"curl -X {self.details['method']}"]
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.
"""
parts = [f"curl -X {shlex.quote(self.details['method'])}"]
for header, value in (self.details["headers"] or {}).items(): for header, value in (self.details["headers"] or {}).items():
parts.append(f"-H '{header}: {value}'") parts.append(f"-H {shlex.quote(f'{header}: {value}')}")
if self.details["data"]: if self.details["data"]:
parts.append(f"--data '{self.details['data']}'") parts.append(f"--data {shlex.quote(str(self.details['data']))}")
elif self.details["json"]: elif self.details["json"]:
parts.append(f"--data '{_json.dumps(self.details['json'])}'") parts.append(f"--data {shlex.quote(_json.dumps(self.details['json']))}")
parts.append(f"'{self.details['url']}'") parts.append(shlex.quote(str(self.details["url"])))
if self.details["proxy"]: if self.details["proxy"]:
parts.append(f"--proxy '{self.details['proxy']}'") parts.append(f"--proxy {shlex.quote(str(self.details['proxy']))}")
return " \\\n ".join(parts) return " \\\n ".join(parts)
+68 -34
View File
@@ -23,10 +23,36 @@ import warnings
import aiohttp import aiohttp
from yarl import URL from yarl import URL
from commons import aretry
from .preview import RequestPreview from .preview import RequestPreview
from .responses import Response, FailureResponse 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__) log = logging.getLogger(__name__)
DEFAULT_ATTEMPTS = 3 DEFAULT_ATTEMPTS = 3
@@ -70,9 +96,15 @@ class ExtendedSession:
proxy/retry/preview logic in this class never touches the session object proxy/retry/preview logic in this class never touches the session object
directly (only _raw_request, the cookie methods, and close do), so those directly (only _raw_request, the cookie methods, and close do), so those
features work unchanged on any backend. 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( return aiohttp.ClientSession(
headers=headers,
timeout=aiohttp.ClientTimeout( timeout=aiohttp.ClientTimeout(
total=timeout, total=timeout,
connect=timeout / 2, connect=timeout / 2,
@@ -208,10 +240,8 @@ class ExtendedSession:
def preview(self, method, url, **kwargs): def preview(self, method, url, **kwargs):
"""build a RequestPreview for a request without sending it""" """build a RequestPreview for a request without sending it"""
proxy = self._get_proxy(url, kwargs.pop("proxies", None)) proxy = self._get_proxy(url, kwargs.pop("proxies", None))
if kwargs.get("headers"): merged = {**self._default_headers, **(kwargs.pop("headers", None) or {})}
headers = self._apply_overwrites(kwargs.pop("headers")) headers = self._apply_overwrites(merged)
else:
headers = dict(self.get_headers())
timeout = kwargs.get("timeout") timeout = kwargs.get("timeout")
timeout_total = timeout if isinstance(timeout, (int, float)) else None timeout_total = timeout if isinstance(timeout, (int, float)) else None
@@ -266,7 +296,8 @@ class ExtendedSession:
kwargs["proxy"] = self._get_proxy(url, kwargs.pop("proxies", None)) kwargs["proxy"] = self._get_proxy(url, kwargs.pop("proxies", None))
debug = kwargs.pop("debug", False) 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()} kwargs["headers"] = {str(k): str(v) for k, v in kwargs["headers"].items()}
timeout = kwargs.get("timeout") timeout = kwargs.get("timeout")
@@ -282,7 +313,10 @@ class ExtendedSession:
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 aiohttp.ClientError as error: except (aiohttp.ClientError, asyncio.TimeoutError) as error:
# a total ClientTimeout raises a bare asyncio.TimeoutError, which is NOT an
# aiohttp.ClientError subclass — wrap it into the same typed path so direct
# callers get a consistent failure instead of a raw timeout
raise aiohttp.ClientError(f"client error for {url}: {error}") from error raise aiohttp.ClientError(f"client error for {url}: {error}") from error
async def request_with_retries( async def request_with_retries(
@@ -297,42 +331,42 @@ class ExtendedSession:
(backoff_base ** attempt). (backoff_base ** attempt).
""" """
attempts = attempts or DEFAULT_ATTEMPTS attempts = attempts or DEFAULT_ATTEMPTS
last_error = None body_data, body_json = _route_body(data)
if debug: if debug:
preview = self.preview( preview = self.preview(
method=method, url=url, params=params, method=method, url=url, params=params,
data=None if isinstance(data, dict) else data, data=body_data, json=body_json,
json=data if isinstance(data, dict) else None,
headers=headers, proxies=proxies, timeout=timeout, headers=headers, proxies=proxies, timeout=timeout,
).as_curl() ).as_curl()
log.info("[aioweb.debug]\n%s\nproxies: %s inject: %s", preview, self.proxies, self.inject) log.info("[aioweb.debug]\n%s\nproxies: %s inject: %s", preview, self.proxies, self.inject)
for attempt in range(attempts): async def attempt():
try: response = await self.request(
response = await self.request( method=method, url=url, params=params,
method=method, url=url, params=params, data=body_data, json=body_json,
data=None if isinstance(data, dict) else data, headers=headers, proxies=proxies, timeout=timeout, debug=debug,
json=data if isinstance(data, dict) else None, )
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)
if response.status_code in retry_statuses: raise _RetryStatus(response)
last_error = f"retryable status {response.status_code}" return response
log.warning("attempt %d: %s for %s", attempt + 1, last_error, url)
else:
return response
except aiohttp.ClientError as error:
last_error = f"client error: {error}"
log.warning("attempt %d: %s, retrying", attempt + 1, last_error)
except Exception as error:
last_error = f"unexpected error: {error}"
log.exception("attempt %d: %s, retrying", attempt + 1, last_error)
if attempt < attempts - 1: try:
await asyncio.sleep(backoff_base ** attempt) return await aretry(
attempt, attempts=attempts, backoff=1.0, factor=backoff_base,
log.error("all %d attempts failed for %s", attempts, url) jitter=False, on=(Exception,),
return FailureResponse(reason=last_error, url=url) )
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 aiohttp.ClientError as 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:
log.error("all %d attempts failed for %s (unexpected: %s)", attempts, url, error)
return FailureResponse(reason=f"unexpected error: {error}", url=url)
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# lifecycle # lifecycle