Repository navigation
Add AsyncSession for asyncio - #155
Merged
Merged
Conversation
Each request runs Session.execute_request in a worker thread, either the event loop's default pool or an executor passed by the caller. Requests run on a shallow copy of the session with its own headers and cookie jar, so concurrent requests never share mutable state across threads; the cookies they receive are merged back on the event loop thread. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Covers overlapping requests, cookies from concurrent responses, and inferred Content-Type staying with its own request. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Session.timeout_seconds is a float and the library is sent timeoutMilliseconds since the response-correctness changes, and Session no longer writes the inferred Content-Type into its headers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FlorianREGAZ
force-pushed
the
feat/async-session
branch
from
October 10, 2026 11:23
3e730c7 to
3571f1e
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds
tls_client.AsyncSessionfor asyncio code.Session(forwarded as*args, **kwargs) and wraps aSession. There is one extra keyword-only argument,executor.get/options/head/post/put/patch/delete/execute_requestmethods run the blockingSession.execute_requestwithloop.run_in_executorand return the normalResponse. Request options are forwarded as**kwargs, so new options added toSessionwork here without changes.executordefaults to the event loop's thread pool, which runs at mostmin(32, cpu_count + 4)requests at once. To run more at once, pass your ownThreadPoolExecutor.async with AsyncSession(...)closes the session on exit.await close()callsSession.close(), which callsdestroySession.headers,cookies,proxies,paramsandtimeout_secondsare typed properties with setters that read and write the wrapped session. Every other setting is on.session, the wrappedSession. There is no__getattr__, and mypy strict passes.asyncio.gatherexample.Fixes #36
Concurrency safety
The native Go session can be used concurrently. The Python side of
Session.execute_requestcan't, because it writes to state that other requests on the same session read at the same time:session.headershas noContent-Typeyet, it writes one there (this bug is already marked xfail intest_inferred_content_type_applies_to_one_request_only). If two requests share the session, one can send the other's body type, e.g. a form body labelledapplication/json. Each request also iteratessession.headers, and aCaseInsensitiveDict(anOrderedDictunderneath) raisesRuntimeErrorif another thread inserts a key during that loop.merge_cookies(self.cookies, ...)writes the request's cookies into the session jar. The request then iterates that jar to buildrequestCookies, andextract_cookies_to_jarwrites the response cookies into it.http.cookiejar.CookieJarholds its RLock inset_cookie, but__iter__walks the nested dicts without the lock. Under the GIL this mostly works becausesorted(dict.keys())doesn't let other threads run partway through. That is an implementation detail, and free-threaded builds don't have it. Also, if a request reads cookies while another thread is still writing them, it can send a partial or stale set.I didn't want one lock around all of
execute_request, because that would make requests run one at a time. A lock around only the unsafe parts isn't possible from outsideSessionwithout editingsessions.py. SoAsyncSessionmakes sure worker threads never touch shared mutable state:AsyncSessionmakes a shallowcopy.copyof the wrappedSession. The copy keeps the same_session_id, so it uses the same native session and its connection pool. It gets its own copy ofheadersand of the cookie jar. All other settings are shared, andexecute_requestonly reads them.execute_requeston that copy. Changes it makes to headers and cookies stay in the copy.response.cookiesare merged into the shared jar.Sessiondoes the same. Only the new cookies are merged, not the whole copy, so a slow request can't overwrite newer cookies with its old snapshot.All reads and writes of the shared headers and jar happen on the event loop thread, where user code also runs, so no locks are needed. Every request uses the headers and cookies the session had when it started, like concurrent requests in a browser. The inferred
Content-Typenow applies only to its own request. ForAsyncSessionthis fixes the xfail bug above, which is still there forSession.Limits, documented in the docstring and README:
AsyncSessionfrom one event loop. Using it from several threads or loops at once is not supported.close()doesn't wait for requests that are still running. Await them first.Testing
tests/unit/test_async_sessions.py(24 tests) uses the existingfake_libfixture. It covers forwarding of constructor arguments and keyword arguments, property reads and writes, every HTTP method, use of one native session id, use of a custom executor, merging of response and request cookies,Content-Typenot leaking intosession.headers, errors from the library,close(), andasync withclosing the session on error.tests/integration/test_async_concurrency.py(6 tests) uses the real native library against a localThreadingHTTPServerstarted in a thread, with no external network. It covers basic GET and POST,async with, andasyncio.gatherof 20 requests to an endpoint that sleeps 0.3s. That test asserts the total is under a third of the 6s it would take one at a time (locally it takes about 0.31s) and that more than half of the requests were in flight on the server at once. It also checks that cookies from 20 concurrent responses all end up in the jar and are sent back, and that 5 concurrent JSON posts and 5 form posts each keep their ownContent-Type. When I changedAsyncSessionto call the wrapped session directly instead of a copy, that last test failed.asyncio.runinside plain test functions, so there's no new dependency. The integration file is not namedtest_async_sessions.pybecause the test directories have no__init__.py, and pytest refuses two test modules with the same basename.🤖 Generated with Claude Code