Compare commits

...

12 Commits
v0.1.0 ... main

Author SHA1 Message Date
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
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
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
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
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
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
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
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
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
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
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
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 129 additions and 50 deletions

2
.gitignore vendored
View File

@ -1,5 +1,5 @@
# claude # claude
CLAUDE.md .claude/
# python # python
__pycache__/ __pycache__/

View File

@ -11,17 +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@v0.1.0 aioweb @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioweb.git@v0.1.5
``` ```
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.5"
``` ```
Requires `aiohttp` and `yarl` (pulled transitively). Requires `aiohttp` and `yarl` (pulled transitively).
Drop the `@v0.1.5` suffix from the line above to install the latest unpinned.
## Usage ## Usage
```python ```python
@ -128,6 +130,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`. 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.

View File

@ -4,13 +4,17 @@ build-backend = "hatchling.build"
[project] [project]
name = "aioweb" name = "aioweb"
version = "0.1.0" version = "0.1.5"
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"]

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,22 @@ 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"] is not None:
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"] is not None:
parts.append(f"--data '{_json.dumps(self.details['json'])}'") # is-not-None, not truthiness: an empty-but-valid body ({} / []) must still
parts.append(f"'{self.details['url']}'") # render rather than being dropped as falsy
parts.append(f"--data {shlex.quote(_json.dumps(self.details['json']))}")
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)

View File

@ -92,7 +92,9 @@ class Response:
"""parsed JSON content, or None if not valid JSON""" """parsed JSON content, or None if not valid JSON"""
try: try:
return _json.loads(self.text()) 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 return None
def raise_for_status(self): def raise_for_status(self):

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,
@ -92,10 +124,13 @@ class ExtendedSession:
def _apply_overwrites(self, request_headers): def _apply_overwrites(self, request_headers):
"""apply static overwrites and ephemeral headers to a request's headers""" """apply static overwrites and ephemeral headers to a request's headers"""
request_headers = dict(request_headers or {}) 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: if self.inject or header in request_headers:
request_headers[header] = value 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: if self.inject or header in request_headers:
value = value_callable() value = value_callable()
if isinstance(value, dict): if isinstance(value, dict):
@ -208,10 +243,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 +299,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,6 +316,12 @@ 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 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: except aiohttp.ClientError as error:
raise aiohttp.ClientError(f"client error for {url}: {error}") from error raise aiohttp.ClientError(f"client error for {url}: {error}") from error
@ -297,42 +337,48 @@ 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=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, debug=debug, headers=headers, proxies=proxies, timeout=timeout, debug=debug,
) )
if response.status_code in retry_statuses: if response.status_code in retry_statuses:
last_error = f"retryable status {response.status_code}" log.warning("retryable status %s for %s", response.status_code, url)
log.warning("attempt %d: %s for %s", attempt + 1, last_error, url) raise _RetryStatus(response)
else:
return 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: except aiohttp.ClientError as error:
last_error = f"client error: {error}" log.error("all %d attempts failed for %s (client error: %s)", attempts, url, error)
log.warning("attempt %d: %s, retrying", attempt + 1, last_error) return FailureResponse(reason=f"client error: {error}", url=url)
except Exception as error: except Exception as error:
last_error = f"unexpected error: {error}" log.error("all %d attempts failed for %s (unexpected: %s)", attempts, url, error)
log.exception("attempt %d: %s, retrying", attempt + 1, last_error) return FailureResponse(reason=f"unexpected error: {error}", url=url)
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)
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# lifecycle # lifecycle