Conversation
pytest-httpserver closes the connection after every response, so it cannot show whether a client reuses connections. This server keeps each connection open until the client closes it, answers every request with the same JSON body, and counts the connections it accepted, the ones still open and the requests it read. It can delay its answers and notices a client that hangs up while it waits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
permit.sync.Permit used to run every blocking call in an event loop of its own, so no HTTP session could outlive a call (PER-16344). Each client now starts a daemon thread with one event loop on its first call, and every blocking method of the client and of the API objects it hands out is submitted to that loop and waited for, from any number of threads. What the old wrapper guaranteed still holds: the call's coroutine sees the caller's context variables, including the call site that deprecation warnings name; an exception reaches the caller with its type and traceback; a call from a thread that runs an event loop works; and Ctrl+C while waiting cancels the call. A blocking call or close() made on the loop's own thread raises RuntimeError instead of deadlocking. close() and `with Permit(...) as permit:` wait for the calls in flight, close the client's sessions on the loop and stop the thread. close() is idempotent and the client stays usable: the next call starts a new thread. A wait_for_sync() copy shares the client's loop, and closing either closes the sessions of both. A client that is never closed has its sessions closed when it is garbage collected and its thread stopped once nothing references the loop; at interpreter exit, the calls still in flight are cancelled and every loop is closed. A process forked after a call starts a thread of its own on its next call. Objects of a SyncClass class that no client holds keep running each call in an event loop of their own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
For the async and the sync client, it makes N check() calls one after the other against the local server that counts TCP connections, and prints how many connections they opened and how long each call took (mean, p50, p95). Run it with `uv run --locked python -m tests.benchmark_connection_reuse --calls 200`. It calls close() only where the client has one, so it also measures releases that predate it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every request opened and closed its own aiohttp.ClientSession, so each call paid for a new TCP connection, and a TLS handshake to a remote PDP or API (PER-16344). The client now keeps one session for the Permit API and one for the PDP per event loop, created by the first request from that loop and shared by all of its APIs. A session belongs to the loop that made it, so a client used under successive asyncio.run() calls gets one per run. The sessions carry no headers, base URL or timeout: each request brings the ones its own session carried before. They keep no cookies, as a session used for one request kept none, so the bytes on the wire are the same. The base URL is resolved and checked per request as aiohttp.ClientSession(base_url=...) did it. The connector keeps aiohttp's default keep-alive and has no connection cap, as when every request had a session of its own. A session is closed when its loop shuts down its async generators, as asyncio.run() and asyncio.Runner do, or when its client is garbage collected while the loop runs. The session of a loop closed without that is dropped by the next request from another loop. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`await permit.close()` closes the client's sessions: the one of the loop it runs on, those of loops already closed, and those of loops running in other threads, on those loops, waiting for them. A loop that is neither running nor closed keeps its session until it shuts down or close() runs on it. close() can be called again, and a request sent after it opens new connections. `async with Permit(...) as permit:` closes the client on exit. A client yielded by wait_for_sync() shares the sessions of the client it was made from and leaves closing them to that client: its close() does nothing. permit.sync.Permit inherits this coroutine. Until it overrides close() with a blocking one, tests/test_fix_sync_parity.py reports close() as still async on the blocking client. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A session whose loop is still open as the interpreter exits would be reported unclosed by aiohttp when the interpreter destroys it. At exit, the SDK now closes the session of a loop that is not running on that loop, hands the close to a loop still running in another thread without waiting for it, and marks the session of a loop closed without shutting down closed, since its connections cannot be closed any more. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add a Connections section to the README: what the client keeps open, how close() and async with close it, what happens to a client that is never closed, and the cases that need a close() call. 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>
SyncPermit._close_sessions now awaits the async client's close() on the background loop, so close(), with, garbage collection and interpreter exit close the sessions the sync client's calls reuse. The two connection reuse tests that waited for the shared sessions now run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A copy of the sync client shared its client's thread and sessions, and its close() stopped both, while the async copy's close() does nothing. The sync copy's close() now does nothing as well, and only the client that owns the sessions registers the closer its loop runs on close. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
permit.sync.Permit inherited the async client's __aenter__ and __aexit__, so async with on it would close it with a blocking call and then fail awaiting None. It now raises a TypeError that points to with, or to the async client. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The async and the sync lifecycle tests each brought a local HTTP/1.1 server that counts connections. tests/keepalive_server.py now serves both suites and the benchmark: per-path answers and delays, the requests it read, opened and closed counts, waits on both, and a delayed answer that a client closing its connection cuts short. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A child made by fork() inherited its parent's sessions, keyed by the parent's event loops. Those loops look running but have no thread in the child, so close() in the child, and the sync client's exit hook, waited for them forever. The child now keeps them aside, untouched, since their connections are the parent's, and resets the sessions' lock; its own requests open sessions of their own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The README's Connections section now covers the blocking client next to the async one: its background thread, close() and with, cleanup of a client never closed, the call it refuses on its own thread, and what wait_for_sync() copies and forked children do with the connections. CONTRIBUTING.md points to the keep-alive test server and the benchmark. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The consumer that test_typing_surface.py type-checks already covered the blocking client's close() and with. It now covers the async client's too: async with yields the client, close() must be awaited, and a plain with on the async client stays an error. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Under pydantic 1, import permit warns on stderr, which the test reads for anything else the child or the parent reports. The sync lifecycle scripts ignore that one warning the same way. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A client freed by the cyclic garbage collector, as one held by an exception it raised is, was collected together with its open aiohttp sessions, so aiohttp reported them unclosed before their close ran. On CPython 3.13 and later the sync client's finalizer, which reached the sessions through a view sharing the client's attribute dict, also let the collector clear them while still in use: a dict's values live in the object that owns it there, and the collector reaches them only through that object. A finalizer of each LoopSessions now holds its sessions apart from it, so they stay reachable until closed. Once the LoopSessions is collected, it closes the sessions of running loops on their loops, and keeps the others until their loop shuts down or, for a closed loop, the next request marks them closed. The sync client's own collection finalizer and its view are gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A small run, in an interpreter of its own, checks that the benchmark still runs against the keep-alive test server and that each client opens one connection for its sequential check() calls. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The close a collected LoopSessions hands to a running loop was a coroutine created in the finalizer's thread. A loop that stopped and closed before running it dropped it unawaited, and Python reported that. The loop now creates the coroutine when it runs the close, and keeps the task until it is done. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Async close() took every closable session out of the client, then awaited a hand-off to each loop running in another thread. When such a loop ended before running that close, close() raised the CancelledError of the close that loop's asyncio.run() cancelled; when it stopped first, close() waited forever. Either way the sessions after it were neither closed nor tracked any more, so aiohttp reported them unclosed once collected. close() now closes the sessions side by side and raises the first error once all have been tried. It waits for a loop in another thread only while that loop runs, and a close that loop cancelled is not an error: the loop closes the session as it shuts down its async generators. A session still open afterwards, including when close() itself is cancelled, is kept for the next close() or request. The close handed to another loop is created on that loop, and closes the session before its closer, so a loop shutting down meanwhile finds the session closed rather than its closer running. A request never reuses a session that is closed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The permit fixture's teardown called close(), which waits for the client's loop thread. If the re-entrancy guard regressed, the re-entrancy tests deadlocked that thread, so after they failed the teardown hung the whole run. The teardown now closes the client from a daemon thread and fails after five seconds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
close() detached the client's loop thread before waiting for the calls in flight. A call made meanwhile started a new thread, whose session the old loop's close then closed under it, so the call failed with a PermitConnectionError, and close() returned with a thread running. A second close() made meanwhile returned at once, before the thread had stopped. While a close() stops the thread, a call from another thread now waits for it to finish and then starts a new thread, and another close() waits for it too. A call or close() made on the thread being stopped raises RuntimeError, as one on the client's running thread does. A child forked during a close() drops that close, as it drops the running thread, so its first call does not wait for it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The blocking client got a call's exception through the future that asyncio.run_coroutine_threadsafe() fills, which copies it through asyncio's own conversion. On Python 3.11 and 3.12 that replaces a TimeoutError, such as a request timeout, with a new one that has neither its traceback nor its cause, so the caller lost where it came from. The call's task now returns the exception it caught as its result, and the calling thread raises that exception itself, with its traceback and cause. The calling thread also drops its references to the future and the exception as it raises, so a client whose call raised is freed by reference counting, without a cycle through the traceback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An async client driven on a loop closed with loop.close() alone, which does not shut down its async generators, keeps that loop's keep-alive connection open: nothing can close it on a closed loop. The next request marks the session closed, and the garbage collector then frees the connection, which Python reports with a ResourceWarning. Before connections were reused, every request closed its own. A test against the keep-alive server pins this, and the README says when the warning shows up and that close() run on the loop before loop.close() avoids it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The docs said that close() on a client yielded by wait_for_sync() does nothing. That holds only with proxy_facts_via_pdp on. With it off, the default, wait_for_sync() logs a warning and yields the client itself, so close() on it closes the client's connections and, on the blocking client, stops its thread. The README, both close() docstrings and wait_for_sync()'s now say so, and a test pins the default case. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
_finalize_when_collected wrapped weakref.finalize with atexit turned off and had one caller, while permit/utils/http_sessions.py sets the same flag inline. The background loop now does it inline too, with the same justification for the type: ignore. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every API object, HTTP client and enforcer builds a LoopSessions of its own, and a Permit client replaces them all with its two. The class docstring now says so, and that the replaced ones open no session and are collected right away. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tests/test_sync_lifecycle.py derived REPO_ROOT from the installed permit package, while every other test module uses its own path. Its subprocess scripts import tests.keepalive_server from REPO_ROOT, so against a non-editable install they would have looked in site-packages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
permit.sync.Permit.__aenter__ raises TypeError at runtime, but it was annotated NoReturn, which type checkers accept anywhere, so mypy --strict let `async with SyncPermit(...) as permit:` through and typed permit as Any. It is now annotated to return None, which mypy reports as an incompatible "async with". The type-check consumer pins the mistake, and the runtime test of the refusal ignores it with a reason. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
close() closed the session of the loop it runs on through that session's closer generator. A session whose close failed is kept for the next close(), but a closer that had raised is finished, so its aclose() would no longer close that session. close() now closes the session itself first, then its closer, as the close it hands to another loop already does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On a free-threaded build, an object that another thread releases is freed by the thread that created it, once that thread runs again. The test of a client whose call raised now has the client's loop thread run a callback, and polls the main thread, before it checks that the client was freed, so it passes on free-threaded 3.14 as well. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The exit hook closed the session of a loop that is not running through the session's closer alone. It now runs the same close as close() and the hand-off to another loop: the session first, then its closer, so a session kept after its closer failed is closed at exit as well. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dependency Security AuditScanned: pyproject.toml dependencies + dev group, resolved at Python 3.10 (the current resolution, and the lowest versions the published specs permit under each pydantic major) ✅ No known vulnerabilities found. Both the resolved dependency set and the lowest versions the published specs permit are clean at HIGH and CRITICAL. |
permit/api/base.py imports both directly; they came in only through aiohttp. The floors are the first releases with wheels for every supported Python, so a floor install never builds them from source. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Freeing the client at `del` can stop and close its background loop before the test schedules its callback, so the test raised "Event loop is closed" on a fast runner. It now treats a closed loop as the client already freed, and still checks that the client is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Freeing the client at `del` can stop its background loop before the test's callback runs, so waiting for that callback timed out on a slow runner. The wake-up is now best effort, and the test checks the one thing it is about: the client is freed. 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>
zeevmoney
added this pull request to stack #145
October 2, 2026 18:24
This branch has not been deployed
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.
Linear issues
Why
Every request opened and closed its own
aiohttp.ClientSession: the five HTTP methods ofSimpleHttpClient(used bypermit.api.*,permit.elementsandpermit.pdp_api.*) and the five PDP calls of the enforcer (check,bulk_check,authorized_users,get_user_permissions,get_user_tenants). Each call paid for a new TCP connection, plus a TLS handshake when the PDP or the API is reached over https. The blocking client also created a new event loop for every call.What changed
Async client (
permit.Permit)permit/utils/http_sessions.py:LoopSessionskeeps one aiohttp session per event loop, opened by the first request from that loop. Each client has two: one for the Permit API, one for the PDP. Sessions carry no headers, base URL or timeout: each request passes its own, resolved asClientSession(base_url=...)resolved it, so the bytes on the wire are unchanged (the existing wire tests pass unchanged). Sessions useTCPConnector(limit=0)andDummyCookieJar.Permit._share_sessions()gives the client's two sessions to every API object, HTTP client and the enforcer. No constructor signature changed.await permit.close()andasync with Permit(...) as permit:.close()closes the session of its own loop, marks closed the sessions of closed loops, and hands the close to loops running in other threads, waiting for each while that loop runs. The sessions are closed side by side: one that fails to close does not stop the others, and the first error is raised afterwards. A session still open afterwards (its loop stopped first, its close failed, orclose()was cancelled) is kept for the nextclose()or request.close()can be called again, and a request after it opens new connections.close(), sessions are closed when a loop shuts down its async generators (asyncio.run(),asyncio.Runner, pytest-asyncio), when the client is garbage collected while its loop runs (also through the cycle collector), and at interpreter exit. A forked child sets the sessions it inherits aside and opens its own.Blocking client (
permit.sync.Permit)permit/utils/sync.py: each client runs its blocking calls, and those of every API object it holds, on one event loop in a daemon thread namedpermit-sync-loop, started on the first call. The deprecation call site is set in each call's task, the caller's other context variables reach it, many threads can share one client, and a call's exception is raised in the caller with its original type, traceback and cause.permit.close()andwith Permit(...) as permit:wait for the calls in flight, close the sessions on the background loop, then stop and join the thread. A call orclose()that another thread makes meanwhile waits for it to finish. A blocking call orclose()made on the client's own background thread raisesRuntimeErrorinstead of deadlocking.async withon the blocking client raisesTypeError, and type checkers reject it.close().Both clients
proxy_facts_via_pdpon,wait_for_sync()yields a copy that uses its parent's sessions (and, on the blocking client, its thread). The copy'sclose()does nothing, and the parent'sclose()closes them. With it off (the default),wait_for_sync()yields the client itself.tests/keepalive_server.py, a local HTTP/1.1 server that keeps connections open and counts them;tests/test_async_session_lifecycle.py;tests/test_sync_lifecycle.py;tests/benchmark_connection_reuse.py, withtests/test_benchmark_connection_reuse.pyrunning it in the offline suite; and the async lifecycle intests/type_check/consumer.py.Behaviour changes
Permit.close(),Permit.__aenter__/__aexit__,permit.sync.Permit.close(),__enter__/__exit__.permit.sync.Permitruns its calls on a daemon thread per client, started on the first call. Calls from many threads run concurrently on that loop. A call orclose()made on that thread raisesRuntimeError; before, such a call ran in a worker thread.check()can fail withPermitConnectionError.loop.close()alone leaves that loop's keep-alive connections open until the garbage collector frees them, and Python reports each with aResourceWarning. Before, every request closed its own connection.permitregisters atexit hooks and, outside Windows,os.register_at_forkhooks. On Python 3.12+, forking after a blocking client has made a call triggers Python's own DeprecationWarning about forking a process that runs threads.SimpleHttpClient(internal) raisesTypeErrorforclient_configkeys other thanbase_url,headersandtimeout, and no longer writestimeoutinto the dict it is given. The unused**kwargspassthrough of both_build_http_clientmethods is gone.permit/api/base.pyimportsyarlandmultidictdirectly, so both are now declared in[project].dependencies:multidict>=6.7.0,<7andyarl>=1.21.0,<2. Both are aiohttp dependencies already. These floors are the first releases with wheels for every supported Python (3.10 to 3.14t), so they are higher than aiohttp's own floors.SyncClassobjects that no client holds, such asSyncPermitApiClient(config)built directly, still run each call in an event loop of their own.Release notes
Connections are now reused. Each client keeps one HTTP connection pool for the Permit API and one for the PDP, instead of opening a new connection, and a TLS handshake, for every request. In a local benchmark of 500 sequential
check()calls on loopback, each client opened 1 connection instead of 500. The median time per call went from about 0.39-0.44 ms to 0.17-0.18 ms on the async client, and from 0.52-0.54 ms to 0.23-0.24 ms on the blocking client. Against a remote PDP or over https, each reused connection also saves a round trip and a TLS handshake.await permit.close()andasync with Permit(...) as permit:on the async client;permit.close()andwith Permit(...) as permit:onpermit.sync.Permit.close()can be called again, and a closed client reconnects on its next call.proxy_facts_via_pdpon, a client yielded bywait_for_sync()uses its parent's connections, and its ownclose()does nothing. With it off (the default),wait_for_sync()yields the client itself.asyncio.run()(or anything else that shuts down the loop's async generators) ends, when the client is garbage collected while its loop runs, or at interpreter exit. If you drive an event loop yourself, callawait permit.close()on it beforeloop.close(). A loop closed without that leaves its connections open until the garbage collector frees them, and Python then reports aResourceWarning, which fails test suites that turn warnings into errors.permit.sync.Permitnow runs its calls on one event loop per client, in a background daemon thread it starts on the first call, so calls from every thread share the client's connections.close()waits for calls in flight, and a call orclose()made from another thread meanwhile waits for it. A client that is never closed is cleaned up when it is garbage collected or at interpreter exit, and the thread never blocks exit. A blocking call orclose()made on the client's own background thread raisesRuntimeErrorinstead of deadlocking.async withon the blocking client raisesTypeError, and type checkers reject it.fork()leaves its parent's connections alone and opens its own.check()and the API's update calls, are not retried.How it was tested
asyncio.run()calls;close(), the context managers, idempotent close and use after close;close()racing calls and a secondclose();close(), and sessions that fail to close;close();close(), and timeouts keeping their traceback.pytest --collect-only -m e2e: 42 collected. CI script tests: 223 passed. API coverage report: exit 0. pre-commit: all hooks pass.uv lock --check, actionlint and zizmor: clean.uv run --locked python -m tests.benchmark_connection_reuse --calls 500, 3 runs each on 127.0.0.1 (Python 3.11.14, macOS arm64). The base numbers run the same script against thepermitpackage of ef5a791:gatheroutcomes no reachable path distinguishes). Earlier rounds checked the async and sync units and the merge.Owner actions before merge
tests/test_fix_sync_parity.py(the asyncclose()lands before the blocking client overrides it). Every later commit passes the offline suite.TCPConnector(limit=0)), aiohttp's 15 s keep-alive, and no retry of POST and PATCH requests on a dropped pooled connection.loop.close()alone (aResourceWarningat garbage collection).asyncio.TimeoutError, and anapi_urlorpdpwith a path breakspermit.api,permit.elementsandpermit.pdp_api.🤖 Generated with Claude Code