Repository navigation
feat(tts): share one TLS context across sessions and keep TTS HTTP connections for 120 s - #284
Conversation
Every TTSSession and AsyncTTSSession connect built a new default TLS context, which loads and parses the CA certificates (about 15 ms with a CA file, as on Debian and Ubuntu system Pythons, and more on a busy host) before the first byte. Sessions now take one process-wide context (_shared_tls_context(), the streaming client's write-first context built on first use), so only the first connect pays for it. The streaming STT client is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KcyY6MAEUVjqBt5pMpetvD
TextToSpeech and AsyncTextToSpeech send their requests through a pool of their own (Client.tts_http_client / AsyncClient.tts_http_client), built on first use, whose idle connections stay settings.tts_keepalive_expiry seconds (120 by default, HTTP/1.1). A voice agent's next sentence a few seconds after the previous one reuses its connection instead of paying the TCP and TLS handshakes again under httpx's 5 s default. The pool of the other APIs and settings.keepalive_expiry are unchanged, and no request is retried. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KcyY6MAEUVjqBt5pMpetvD
Review of #284. Building the Text-to-Speech pool on first use moved httpx's TLS context build (it loads certifi's CA bundle, about 15 ms with OpenSSL 3.0) into the first request of every TextToSpeech / AsyncTextToSpeech, on top of the context the other APIs' pool still builds at construction. - TextToSpeech and AsyncTextToSpeech build their client's TTS pool when they are constructed. A Client or AsyncClient that never speaks still builds none. - Every TTS pool in the process verifies with one context, httpx's own default context built once (_shared_tts_ssl_context), so a new pool loads no certificates. The other APIs' pool is unchanged. - A forked child drops the TTS pools it inherited (os.register_at_fork), so it never shares the parent's idle connections, now kept 120 s. - Client.close() closes both pools; TextToSpeech.close() uses it. - Docs: the client never reuses a connection idle longer than tts_keepalive_expiry (there is no idle reaper); keepalive_expiry does not apply to TTS requests (README, MIGRATION, CLAUDE.md). - Tests: a server that closes idle connections before the expiry (sync and async), the reuse and expiry tests split so neither depends on timing, a fork test, the shared context, construction-time build and closed clients. The local server moves to tests/unit/tts_http_server.py. Fixes the mypy errors CI's mypy job reports on the changed test files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KcyY6MAEUVjqBt5pMpetvD
|
Holding approval for one fix. TLS is not weakened: both shared contexts keep cert and hostname verification and the stdlib minimum TLS version. The STT client is unchanged, close/aclose cover both pools, and the mypy failure is the runner (setup-python can't find 3.9 on Ubuntu 26.04) before mypy runs; mypy 1.5.1 is clean on the changed files locally. Blocking (public SDK):
Should fix (not blocking):
-- Jeeves |
…t closing them httpx 0.19 to 0.21 close a client in Client.__del__, which takes the pool's lock, so the fork handler's dropped pool could deadlock the child when it was collected. Keeping the pools referenced only moves that to the child's interpreter exit (reproduced on 0.19, 0.20 and 0.21.3) and keeps the parent's sockets open in every child. httpx 0.22, the lowest version the test matrix already runs, has no __del__, so dropping is safe there. Tests: a dropped sync pool that has sent a request is collected without close(), a dropped async pool without the unclosed-client warning, and setup.py's floor stays at 0.22 or later. The first two fail on httpx 0.19, 0.20 and 0.21.3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KcyY6MAEUVjqBt5pMpetvD
…ver infrastructure Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KcyY6MAEUVjqBt5pMpetvD
1.8 users upgrading to 1.9 do not read "Migrating to 1.0". The note goes to the 1.9.0 release notes instead (drafted in the PR description), with the other changes a 1.9 upgrade brings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KcyY6MAEUVjqBt5pMpetvD
tts_keepalive_expiry is now None by default. Unset, the TTS pool keeps idle connections 120 s, or keepalive_expiry when that is set shorter, so someone who lowered keepalive_expiry because their network drops idle connections gets the same on TTS. A set tts_keepalive_expiry still wins. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KcyY6MAEUVjqBt5pMpetvD
|
Thanks. Each point, now at 0803e4a:
|
aurpsis-aai
left a comment
There was a problem hiding this comment.
Approved at 0803e4a. All four points are fixed. The httpx floor is now 0.22, so dropping the TTS pools in a forked child no longer closes them (0.19-0.21 close in del), and tox tracks the new floor. With tts_keepalive_expiry unset, TTS keeps 120 s, or keepalive_expiry when that is shorter. The docstring no longer names server infrastructure, and the misplaced MIGRATION note is gone. Merge after #285 so mypy runs green here. Put two lines in the 1.9.0 release notes: the dependency floor is now httpx>=0.22, and headers, hooks or mocks set on client.http_client no longer apply to TTS requests.
-- Jeeves
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KcyY6MAEUVjqBt5pMpetvD
What this speeds up
Two connection costs that an app built on the SDK pays before its first audio. The API's own latency metrics do not include either one.
TTSSessionandAsyncTTSSessionconnect built a new TLS context, which loads and parses the trusted CA certificates before the handshake starts. With a CA file (the system Pythons of Debian and Ubuntu, among others) that takes about 13 to 17 ms on a quiet machine and several times longer on a busy one. Sessions now share one process-wide context: the streaming client's write-first context, built by the first connect (_shared_tls_context()instreaming/v3/_tls.py). The streaming STT client is unchanged.TextToSpeechandAsyncTextToSpeechused the SDK's shared pool, which keeps httpx's 5 s idle expiry, so a voice agent's next sentence more than 5 s after the last one paid for a new TCP and TLS connection. Text-to-Speech requests now use a pool of their own (Client.tts_http_client/AsyncClient.tts_http_client) that keeps idle connections for 120 seconds by default (settings.tts_keepalive_expiry). ATextToSpeechorAsyncTextToSpeechbuilds that pool when it is constructed, so its first request does not wait for it, and every TTS pool in the process connects with one TLS context (httpx's own default context, built once by_shared_tts_ssl_context()), so a new pool loads no certificates. It still speaks HTTP/1.1: HTTP/2 measured no faster.Measured
Interleaved A/B of
masteragainst this branch: the two SDKs ran in alternating processes, with staggered arrivals and one request in flight, against a staging deployment of the Text-to-Speech API in the client's AWS region (us-west-2). Client: system Python 3.12.3 with OpenSSL 3.0.13 and a 120-certificate CA file, on a shared 8-vCPU machine. Each process's first connect is excluded because both arms build a context there. Deltas are branch minusmaster, with bootstrap 95% intervals.masterp50 / p95TTSSessionconnect toBegin(40)AsyncTTSSessionconnect toBegin(40)TextToSpeech.stream()first audio, requests 6 s apart (40)masterrequest opened a new TLS connection (1.0 handshakes per request). Every request on this branch after the first reused its connection (0.0). Load average 16.5 p50 (9.3 to 19.9). The p95 interval includes zero. This row times later requests on one instance: the bench builds the pool before its timed loop and drops each process's first request, so it shows connection reuse, not the first request. The next table covers the first request.First request of a new instance
The first push of this PR built the TTS pool on first use, which moved httpx's TLS context build (about 15 ms) into the first request of every instance. This table times that first request with every pool build inside the timed region. Each sample builds a new
TextToSpeech(api_key=...)orAsyncTextToSpeech(api_key=...)(a client and pool per instance, the README's per-use pattern), starts the timer just before construction and stops it at the first audio chunk of its firststream(). Nothing touches the pool before the timer starts. Three arms ran in rotating order:master, the first push (7a5ba6a, pool built on first use) and this branch now. 12 rounds of 5 instances per process per arm, 0.7 s apart, one request in flight, same staging deployment and client. Load average 10.7 p50 (3.8 to 17.2). The rows are the instances after the first in each process (n=48 per arm). Deltas are this branch minusmaster, with bootstrap 95% intervals.masterp50 / p95masterp50 [95% CI]TextToSpeech: first request to first audioTextToSpeech: construction to first audioAsyncTextToSpeech: first request to first audioAsyncTextToSpeech: construction to first audiomasteron the first request: +19.3 ms p50 [14.2, 36.7] sync and +11.1 ms [3.9, 27.6] async. This branch now is no slower thanmaster. The small p50 gains are at or near noise, so read them as parity, not as a speedup.master(49.7 vs 51.9 ms sync, 61.7 vs 67.1 ms async).AsyncTextToSpeech()orTextToSpeech(api_key=...)takes 12.7 to 13.4 ms p50 against 12.7 ms onmaster, and readingtts_http_clientafter it costs nothing. On the first push that read cost 22 ms p50 (n=30, load average 18.6).Confidence and caveats:
Compatibility and rollout
settings.keepalive_expirywhen that is set shorter, so someone who lowered it because their network drops idle connections gets the same on TTS. Akeepalive_expiryraised past 120 s leaves the TTS pool at 120 s.settings.tts_keepalive_expiry, when set, wins either way. The README and the settings' docstrings say so.Client.__del__, which takes the pool's lock: a child could deadlock when the dropped pool was collected. httpx 0.22, already the lowest version the test matrix runs, has no__del__. Keeping the dropped pools referenced instead only moves the deadlock to the child's interpreter exit (reproduced on 0.19, 0.20 and 0.21.3) and keeps the parent's sockets open in every child.settings.tts_keepalive_expiry(envASSEMBLYAI_TTS_KEEPALIVE_EXPIRY,Noneby default, >= 0 when set),Client.tts_http_client,AsyncClient.tts_http_clientandClient.close(). Version 1.9.0.TextToSpeechorAsyncTextToSpeechbuilds its client's TTS pool at construction. AClientorAsyncClientthat never speaks builds none. Every TTS pool in the process uses one TLS context, httpx's default (certifi's CA bundle, orSSL_CERT_FILE/SSL_CERT_DIRwhen set), read at the first build; a later change takes effect in a new process. The other APIs' pool keeps building its own context per client, as before.TextToSpeech(api_key=...).close()andAsyncClient.aclose()close both pools. On a syncClientyou passed asclient=, the newclient.close()closes both; the README and docstrings say so. A request after closing raises httpx's closed-clientRuntimeError, including when the pool was never built. Constructing an instance on a closed client does not raise; its first request does.close()discards it, or the server closes it. Before reusing an idle connection, httpcore checks it and drops one the server has closed, so a server-side close never fails a request (tested, sync and async). 120 s is also under common NAT idle timeouts (4 minutes on Azure, 350 s on an AWS NAT gateway). A middlebox that silently drops idle connections sooner, with no FIN or RST, would make a reused connection wait for the read timeout. Lowertts_keepalive_expiryin that environment.os.register_at_fork), so a request made before a fork, such as agunicorn --preloadwarm-up, never leaves parent and child sending on one connection. The child builds its own pool on its next request. Dropping never closes the pool, so it takes none of its locks (needs httpx 0.22 or later, see above). POSIX only, since Windows has no fork.SSL_CERT_FILE/SSL_CERT_DIRin place at the first connect. A later change takes effect in a new process.KeepAliveframes. Both hold API capacity while idle.Tests
test_streaming_tls.py: the shared session context is built once, also when 8 threads ask for it at the same time, and keeps verification and the write-first socket class. Real TLS 1.3 handshakes through the sync and asyncio session connects against a local websockets server use the shared context on every connect.test_tts_session.py/test_tts_session_async.py: two sessions overwss://pass the same context to websockets, under the installed release's parameter name. Aws://host passes none.test_tts.py/test_tts_async.py: the TTS pool is separate from the other APIs' pool, keeps idle connections 120 s by default, uses HTTP/1.1, is built when aTextToSpeech/AsyncTextToSpeechis constructed (also on the default client) and not by aClientalone, is built only once under concurrent first use, carrieslast_response, and closes with an owned client orClient.close(); a passed client stays open. Every TTS pool shares one verifying TLS context, built once under 8 concurrent first calls, while the other APIs' pool keeps its own. A closed client constructs without raising and its first request raises.tests/unit/tts_http_server.py) that counts connections: consecutive requests reuse one connection at the default 120 s; a request after a 0.2 s expiry opens a new one; with a server that closes each connection 0.2 s after its response and a 120 s expiry, a request 0.5 s later succeeds on a new connection, sync and async. Those last two fail withhttpx.ReadErrorwhen httpcore's check for idle connections the server closed is disabled, so they cover that case. A forked child uses a pool and connection of its own, and the parent keeps reusing its connection afterwards. No test depends on a timing margin.close()being called, and a dropped open async pool is collected without the unclosed-client warning. Both fail on httpx 0.19, 0.20 and 0.21.3 and pass from 0.22. A third test keepssetup.py's httpx floor at 0.22 or later; it fails on the old 0.19 floor.keepalive_expiryof 2 s gives the TTS pool 2 s (sync and async), one of 300 s leaves it at 120 s, and a settts_keepalive_expirywins over both.mypyjob fails before it runs:actions/setup-python@v4finds no Python 3.9 for theubuntu-latestimage (Ubuntu 26.04), a runner change outside this diff. ci(lint): run the mypy job on ubuntu-24.04, which has Python 3.9 #285 pins that job toubuntu-24.04. Run locally as CI runs it (mypy 1.5.1,--python-version 3.9, no deps installed,--follow-imports=silent --ignore-missing-imports) on the 15 changed files, it is now clean. An earlier version of this description said that run was clean. It was not: it reported 8 errors, one added by this PR (astr | byteshost in a test URL) and 7 already onmasterin files this PR touches (test_tts.pyerror attributes,test_streaming_tls.pyOptional[str]). All 8 are fixed, so the mypy job will pass on these files once the runner is fixed.ruff format --checkandruff check(0.3.5) are clean. The connection tests passed 20 of 20 repeats at load average ~9 on an earlier push.1.9.0 release notes
To paste into the GitHub release when 1.9.0 is cut:
TextToSpeech,AsyncTextToSpeech) use a connection pool of their own,client.tts_http_client, which keeps idle connections 120 seconds by default, so a voice agent's next sentence reuses the connection. If you setkeepalive_expiryshorter, this pool uses it too.tts_keepalive_expirysets it on its own.client.http_clientno longer apply to Text-to-Speech requests. Attach them toclient.tts_http_client.Client.close()closes both pools.TTSSessionandAsyncTTSSessionconnects share one TLS context.🤖 Generated with Claude Code
https://claude.ai/code/session_01KcyY6MAEUVjqBt5pMpetvD