From 7da06443c86817e770d7a091b1db36a71efbc487 Mon Sep 17 00:00:00 2001 From: disqualifier Date: Mon, 29 Jun 2026 17:57:54 -0400 Subject: [PATCH] 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 --- README.md | 4 ++-- pyproject.toml | 2 +- src/aioweb/preview.py | 6 ++++-- src/aioweb/session.py | 12 ++++++++++-- 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 476bb35..30cdd88 100644 --- a/README.md +++ b/README.md @@ -11,13 +11,13 @@ and swap the HTTP client while inheriting everything else. `requirements.txt`: ``` -aioweb @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioweb.git@v0.1.3 +aioweb @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioweb.git@v0.1.4 ``` Direct: ```bash -pip install "aioweb @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioweb.git@v0.1.3" +pip install "aioweb @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioweb.git@v0.1.4" ``` Requires `aiohttp` and `yarl` (pulled transitively). diff --git a/pyproject.toml b/pyproject.toml index b29df7e..0105226 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "aioweb" -version = "0.1.3" +version = "0.1.4" description = "Async HTTP session wrapper over aiohttp — proxies, header overwrites, retries, previews. Config-free, installable." requires-python = ">=3.10" dependencies = [ diff --git a/src/aioweb/preview.py b/src/aioweb/preview.py index 5cc1133..f42f176 100644 --- a/src/aioweb/preview.py +++ b/src/aioweb/preview.py @@ -35,9 +35,11 @@ class RequestPreview: parts = [f"curl -X {shlex.quote(self.details['method'])}"] for header, value in (self.details["headers"] or {}).items(): parts.append(f"-H {shlex.quote(f'{header}: {value}')}") - if self.details["data"]: + if self.details["data"] is not None: parts.append(f"--data {shlex.quote(str(self.details['data']))}") - elif self.details["json"]: + 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']))}") parts.append(shlex.quote(str(self.details["url"]))) if self.details["proxy"]: diff --git a/src/aioweb/session.py b/src/aioweb/session.py index 70c0f8c..0f1a185 100644 --- a/src/aioweb/session.py +++ b/src/aioweb/session.py @@ -124,10 +124,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): @@ -364,6 +367,11 @@ class ExtendedSession: 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 asyncio.TimeoutError: + # a total ClientTimeout surfaces as a bare asyncio.TimeoutError; label it as + # a timeout rather than letting it fall to the generic "unexpected" branch + log.error("all %d attempts timed out for %s", attempts, url) + return FailureResponse(reason="timeout", 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)