overwrite_domain() now rejects a port-bearing replacement immediately at registration time with a clear ValueError, instead of only surfacing a deep yarl error from inside with_host() the first time a matching request is made, far from the misconfiguration site. set_cookie() now raises ValueError when domain resolves to a bare IP host and the jar is the default CookieJar(unsafe=False), which silently drops IP-bound cookies with no store, no send, and no log. Non-IP domains and a caller-supplied unsafe=True jar are unaffected. Signed-off-by: disqualifier <dev@disqualifier.me>
aioweb
Async HTTP session wrapper over aiohttp. Adds session-level proxies, header
overwrites, ephemeral (per-request generated) headers, domain rewriting, request
previews / cURL export, and retry-with-backoff. The byte-sending is isolated behind
one overridable method (_raw_request), so a TLS-fingerprinting backend can subclass
and swap the HTTP client while inheriting everything else.
Install
requirements.txt:
aioweb @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioweb.git@v0.1.13
Direct:
pip install "aioweb @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioweb.git@v0.1.13"
Requires aiohttp and yarl (pulled transitively).
Drop the @v0.1.13 suffix from the line above to install the latest unpinned.
Usage
from aioweb import ExtendedSession
async with ExtendedSession(proxies={"https": "http://user:pass@host:port"}, timeout=15) as s:
resp = await s.request_with_retries("GET", "https://example.com")
if resp: # FailureResponse is falsy
data = resp.json() # or resp.text(), resp.content
Sessions must be closed explicitly — use async with or await s.close(). There is
no __del__ auto-close (unsafe for async resources); leaking a session emits a
ResourceWarning.
Responses
request/request_with_retries return a Response (success or non-retryable status)
or a falsy FailureResponse (all retries failed). Both expose the same surface as
properties, so callers branch uniformly:
status_code,headers,url,reason,cookies,history,redirect_chainis_success(2xx),is_redirectcontent(bytes),text(encoding=None),json()(None if not JSON)raise_for_status()raisesAiowebErroron non-2xxbool(resp)/if resp:isis_success
Retries
request_with_retries retries on exceptions and retryable statuses (429, 500,
502, 503, 504 by default), with exponential backoff (backoff_base ** attempt).
resp = await s.request_with_retries(
"GET", url, attempts=5, backoff_base=2.0,
retry_statuses={429, 503}, # override which statuses retry
)
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
s.overwrite_header("User-Agent", "custom") # replace per request
s.overwrite_inject(True) # also add when absent
s.set_ephemeral("X-Time", lambda: str(time.time())) # generated fresh each request
With inject=False (default) overwrites only replace headers already present in a
request; with inject=True they're added regardless.
Domain rewriting
s.overwrite_domain("internal.local", "127.0.0.1") # host-substring rewrite
Preview / debug
print(s.preview("POST", url, json={"a": 1}).as_curl()) # equivalent cURL command
preview() is synchronous and never touches the backend session — it works before any
event loop is running (e.g. building a preview at import/setup time), not just inside
asyncio.run(). Its default cookie lookup only reads cookies from an already-built
session; if the session hasn't been built yet there are no cookies to read, so it
returns none by default (pass cookies= explicitly to preview cookies for a session
that hasn't sent a request yet). 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
The _raw_request(method, url, **kwargs) -> Response method is the only place that
touches the HTTP client. To use a different backend (e.g. a TLS-fingerprinting client
like curl_cffi), subclass ExtendedSession and override just _raw_request, building
a Response from that backend's primitives:
class MySession(ExtendedSession):
async def _raw_request(self, method, url, **kwargs):
r = await my_client.request(method, url, **kwargs)
return Response(
status_code=r.status, headers=r.headers, content=await r.read(),
url=str(r.url), reason=r.reason,
)
Everything else — header overwrites, ephemeral headers, domain rewriting, proxy
resolution, retries, previews — is inherited. Response is built from primitives
(status, headers, content, url, history) precisely so any backend can produce one.
Migrating from the original
Back-compat shims are in place for the common path:
aiowebResponseis aliased toResponse(the class was renamed) — old imports work.request_retries(session, ...)andtest_proxies(session)remain as module functions.raise_for_statusnow raisesAiowebError(a subclass ofException), soexcept Exceptionstill catches it.
Two changes can't be shimmed without re-introducing the bugs they fix:
is_successis now a property onFailureResponse, not a method. Code that calledfailure.is_success()must drop the parens tofailure.is_success. (Code that wroteif failure.is_successwas previously always-truthy — a bug — and now behaves correctly.)- No
__del__auto-close. Sessions must be closed viaasync withorawait s.close(); a leaked session emits aResourceWarning. The old finalizer-based auto-close was unsafe and was removed.
Changelog
v0.1.13
- Session-default timeout no longer poisons pooled keep-alive connections.
The default
ClientTimeoutsetsock_read=timeout/2alongsidetotal. Underaiohttp>=3.14, that read timer re-arms on every request dispatched over a pooled protocol, including idle connections between requests; when it fires it permanently poisons the pooled connection (SocketTimeoutErroron the next use, instantly, without contacting the server) — a real error from the server (e.g. a 503) could come back as a client-sideFailureResponse(status=0, reason='timeout')instead.sock_readis now dropped from the session default;total(andconnect/sock_connect) still bound every request, and the per-calltimeout=Npath (ClientTimeout(total=N), nosock_read) was already unaffected.
v0.1.12
- Docstring-only. Restored one-line docstrings on
FailureResponse'sredirect_chain,text(),json(), andraise_for_status()— a prior de-bloat pass stripped them below the public tier while theResponsetwins kept theirs. No behavior change.
v0.1.11
preview()no longer requires a running event loop. Its default cookie lookup (cookies=not passed) previously called_cookies_for_url(), which reached the backend session and built it if absent — under aiohttp>=3.14 that needs a running loop, sopreview()raisedRuntimeError('no running event loop')when called before the loop starts, defeating its own pre-loop build/inspect use case (v0.1.7). Now the default cookie lookup is skipped entirely when the session hasn't been built yet (nothing could have been set on a session that doesn't exist);cookies={}and in-loop calls are unaffected.set_cookie(domain=None)now honorspath. Thedomain=Nonebranch returned right afterupdate_cookies(), before the line that sets the morsel'spath— so a shared cookie (set_cookie(name, value, path="/api"), nodomain=) always stored theSimpleCookiedefaultpath="/"instead. The morsel'spathis now set before thedomain=Noneearly return. The domain-bound branch was already correct and is unchanged.
v0.1.10
- Docs-only pass: compressed module/method docstrings and comments that restated README/CLAUDE prose, replaced em-dashes with hyphens. No behavior change.
v0.1.9
_get_proxy()/request()proxy resolution now checksis None, not truthiness. A per-callproxies={}previously fell back to the session's configured proxies instead of disabling them for that call.request()also no longer clobbers a nativeproxy=kwarg with the resolved session/proxies=value (a real IP-unmasking leak) — passing both now raisesValueErrorinstead of silently picking one.Response.text(encoding=...)no longer returns a stale cached decode. A second call with an explicitencoding=previously still returned the first (possibly differently-encoded) cached decode; an explicit encoding now bypasses the cache.preview()now rewrites the url before resolving cookies, matching whatrequest()actually sends — previously cookies were resolved against the pre-rewrite host, which could miss or misattribute host-bound cookies.request_with_retries(attempts=0)now floors to 1 attempt, not silentlyDEFAULT_ATTEMPTS(3).attemptsis checked withis None, not truthiness.
v0.1.8
request()no longer flattensaiohttp.ClientErrorsubtypes. Every failure (connect errors, proxy errors,raise_for_status()-style response errors, redirect limits, ...) was re-raised as a bareaiohttp.ClientError, losing the real subtype and its attributes (.os_error,.status,.headers, ...) — a direct caller doingexcept ClientProxyConnectionError:orif e.status == 401would silently never match. Now the original exception is re-raised as-is (its subtype, attributes, and__cause__all preserved).request_with_retriesstill catches the baseaiohttp.ClientErroracross attempts, so its behavior (and itsFailureResponsereturn on exhaustion) is unchanged.as_curl()now rendersparamsandtimeout. Previously a preview built withparams=silently omitted the query string (and atimeout=omitted--max-time), so adebug=TruecURL replay of a params-driven request hit a different URL than the one actually sent.paramsare now merged into the url's query string (viayarl) andtimeoutis emitted as--max-time.
v0.1.7
get_cookies()now returns real cookies. Previously calledfilter_cookies()with no URL, which only ever returns domain-less shared cookies — every normal domain-bound cookie (including ones set by a realSet-Cookieresponse) 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 honorspath(previously ignored — the cookie always landed atpath="/").domain=Noneis unchanged in meaning but now stores a truly shared cookie (sent to every host) instead of one silently bound tolocalhostonly, which madeset_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, constructingaiohttp.ClientSessionrequires a running event loop; eager construction crashed the common host pattern of attaching a session before the loop starts (e.g.bot.http = ExtendedSession(...)inBot.__init__). The session (and any subclass's_create_sessionoverride) now builds on first access instead.
v0.1.2
- Pinned
commonsto v0.2.1 (retryattemptsfloor fix).
v0.1.1
- JSON list bodies now route to
json=(were wrongly form-encoded viadata=— only dicts went tojson=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/5xxResponseis returned (only a pure-exception failure yieldsFailureResponse). - Retry/backoff moved onto
commons.aretry(shared engine); backoff schedule unchanged. Adds acommonsdependency.
Versioning
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.