Skip to content

Add AsyncSession for asyncio - #155

Merged
FlorianREGAZ merged 6 commits into
mainfrom
feat/async-session
Oct 10, 2026
Merged

FlorianREGAZ merged 6 commits into
mainfrom
feat/async-session

Conversation

@FlorianREGAZ

Copy link
Copy Markdown
Owner

Summary

Adds tls_client.AsyncSession for asyncio code.

  • It takes the same arguments as Session (forwarded as *args, **kwargs) and wraps a Session. There is one extra keyword-only argument, executor.
  • The async get/options/head/post/put/patch/delete/execute_request methods run the blocking Session.execute_request with loop.run_in_executor and return the normal Response. Request options are forwarded as **kwargs, so new options added to Session work here without changes.
  • executor defaults to the event loop's thread pool, which runs at most min(32, cpu_count + 4) requests at once. To run more at once, pass your own ThreadPoolExecutor.
  • async with AsyncSession(...) closes the session on exit. await close() calls Session.close(), which calls destroySession.
  • headers, cookies, proxies, params and timeout_seconds are typed properties with setters that read and write the wrapped session. Every other setting is on .session, the wrapped Session. There is no __getattr__, and mypy strict passes.
  • The README has a new "Example 3 - Async" section with an asyncio.gather example.

Fixes #36

Concurrency safety

The native Go session can be used concurrently. The Python side of Session.execute_request can't, because it writes to state that other requests on the same session read at the same time:

  • Headers. When a request sends JSON or form data and session.headers has no Content-Type yet, it writes one there (this bug is already marked xfail in test_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 labelled application/json. Each request also iterates session.headers, and a CaseInsensitiveDict (an OrderedDict underneath) raises RuntimeError if another thread inserts a key during that loop.
  • Cookies. merge_cookies(self.cookies, ...) writes the request's cookies into the session jar. The request then iterates that jar to build requestCookies, and extract_cookies_to_jar writes the response cookies into it. http.cookiejar.CookieJar holds its RLock in set_cookie, but __iter__ walks the nested dicts without the lock. Under the GIL this mostly works because sorted(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 outside Session without editing sessions.py. So AsyncSession makes sure worker threads never touch shared mutable state:

  1. Before a request starts, on the event loop thread, AsyncSession makes a shallow copy.copy of the wrapped Session. The copy keeps the same _session_id, so it uses the same native session and its connection pool. It gets its own copy of headers and of the cookie jar. All other settings are shared, and execute_request only reads them.
  2. The worker thread runs execute_request on that copy. Changes it makes to headers and cookies stay in the copy.
  3. Back on the event loop thread, the cookies passed with the request and then response.cookies are merged into the shared jar. Session does 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-Type now applies only to its own request. For AsyncSession this fixes the xfail bug above, which is still there for Session.

Limits, documented in the docstring and README:

  • Use an AsyncSession from one event loop. Using it from several threads or loops at once is not supported.
  • If a request is cancelled, its worker thread still runs the request to the end, because ctypes calls can't be interrupted. Cookies from that response are not merged.
  • close() doesn't wait for requests that are still running. Await them first.

Testing

  • tests/unit/test_async_sessions.py (24 tests) uses the existing fake_lib fixture. 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-Type not leaking into session.headers, errors from the library, close(), and async with closing the session on error.
  • tests/integration/test_async_concurrency.py (6 tests) uses the real native library against a local ThreadingHTTPServer started in a thread, with no external network. It covers basic GET and POST, async with, and asyncio.gather of 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 own Content-Type. When I changed AsyncSession to call the wrapped session directly instead of a copy, that last test failed.
  • Tests use asyncio.run inside plain test functions, so there's no new dependency. The integration file is not named test_async_sessions.py because the test directories have no __init__.py, and pytest refuses two test modules with the same basename.
uv run ruff check            # All checks passed!
uv run ruff format --check   # 27 files already formatted
uv run mypy                  # Success: no issues found in 11 source files
uv run pytest                # 143 passed, 10 deselected, 9 xfailed; coverage 93.12%, async_sessions.py 100%

🤖 Generated with Claude Code

FlorianREGAZ and others added 6 commits October 10, 2026 13:22
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
FlorianREGAZ merged commit a208079 into main Oct 10, 2026
30 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Async

1 participant