Repository navigation
fix(streaming): hold sync client TLS reads until the handshake request is written - #265
Merged
Merged
Conversation
… written websockets' sync client starts its reader thread, blocked in recv(), before the calling thread writes the opening handshake request on the same socket. Against servers that send TLS 1.3 post-handshake messages, such as an AWS Application Load Balancer, a small fraction of connections opened this way never receive the HTTP 101 response. RealTimeTranscriber.connect() then succeeds only after its 1 s open timeout and a retry, about 1.5 s late. In 600 sequential connects to such an endpoint, 14 hit this without the change and none with it, and the median connect time was the same (25 ms). The asyncio client is unaffected. For wss:// URIs the sync client now passes a TLS context whose sockets hold reads until the first send() or sendall() has returned, or until shutdown() is called, with a 30 s bound. Certificate and hostname verification are those of ssl.create_default_context(), which websockets uses by default. The context is passed as ssl on websockets 13 and later and as ssl_context on websockets 11 and 12. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019UNrfUwp3uuUFEEbNvY3zH
_WriteFirstSSLSocket extends the ssl.SSLSocket present when the SDK is imported. If gevent monkey-patches ssl after that import, ssl.create_default_context() returns gevent's context, whose wrap_socket() calls sslsocket_class(...) directly, and the stdlib class refuses public construction. Every wss:// connect from the sync client then raised TypeError, which the connect retry loop does not catch. _create_tls_context() now installs _WriteFirstSSLSocket only when it extends the context's sslsocket_class, and otherwise leaves the context as ssl.create_default_context() returned it. SSLContext replacements that do not build sockets from sslsocket_class also keep their own sockets; the module docstring now says so. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019UNrfUwp3uuUFEEbNvY3zH
ccampbell-aai
approved these changes
Sep 28, 2026
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
The sync streaming client (
RealTimeTranscriber, also exported asStreamingClient, inassemblyai.streaming.v3) opens its connection withwebsockets.sync.client.connect(). That function starts the connection's reader thread, which blocks inSSLSocket.recv(), before the calling thread writes the HTTP upgrade request withsendall()on the same TLS socket. When the server sends TLS 1.3 post-handshake messages (session tickets; AWS Application Load Balancers send them), a read that is already blocked while the request is being written occasionally leaves the connection without the101 Switching Protocolsresponse. The connect then waits until its open timeout.With the SDK defaults (
connect_timeout=1.0,max_connection_retries=2,connection_retry_delay=0.5) the affected connect succeeds on the retry, about 1.5 s later than normal, so users see an occasional latency spike rather than an error. With a largerconnect_timeout, the stall lasts that long. In the measurements below it hit between 0.5% and 2.3% of connects made through websockets.This change holds reads on the client's TLS socket until the upgrade request has been written. After that first write, reads and writes run concurrently as before.
The asyncio client (
AsyncRealTimeTranscriber) is unaffected and unchanged.Evidence
Connections to an AWS ALB endpoint that negotiates TLS 1.3, one connect per second. A connect fails when no HTTP 101 arrives before the timeout.
recv()while the request is written (no websockets involved)OP_NO_TICKETon the client contextReproduced on CPython 3.12.3 with OpenSSL 3.0.13 and CPython 3.13.14 with OpenSSL 3.5.7, with websockets 15.0.1 and 16.0.
SDK end to end: 600 cycles of
connect()plusdisconnect(terminate=True)per client against the same kind of endpoint, one connect per second, SDK defaults, Python 3.12.3 with OpenSSL 3.0.13 and websockets 17.1. The two clients ran at the same time as separate processes.master(1.6.1)SSLSocket, TLSv1.3_WriteFirstSSLSocket, TLSv1.3Every retry on
masterwas a single retry logged asWebSocket connect attempt 1/3 failed (timed out while waiting for handshake response); retrying. Leaving the retried connects out, themasterp99 was 75.1 ms. Median disconnect time was about 0.52 s for both.Changes
assemblyai/streaming/v3/_tls.py:_WriteFirstSSLSocket, anssl.SSLSocketsubclass whoserecv()andrecv_into()wait until the firstsend()orsendall()has returned (whether or not it succeeded) orshutdown()has been called. The wait is bounded at 30 s, so a socket that is never written to can still be read._create_tls_context(), which returnsssl.create_default_context()withsslsocket_classset to_WriteFirstSSLSocket. Certificate and hostname verification are those of the default context, which is also what websockets uses when no context is given. If the default context's socket class is one_WriteFirstSSLSocketdoes not extend (for example when gevent patchedsslafter the SDK was imported), the context keeps its own class.assemblyai/streaming/v3/client.py:websocket_connect()now wrapswebsockets.sync.client.connect()and passes that context forwss://URIs. Nothing is passed forws://, where websockets rejects a TLS argument.tests/unit/test_streaming_tls.py.Compatibility
connect()TLS parameter isssl_contextin 11.x and 12.x andsslfrom 13.0 on. 13.0 and later still acceptssl_contextbut emit aDeprecationWarning. The client picks the name from the installedconnect()signature, so no warning is raised.additional_headersis accepted by the sync client from 11.0 on and is unchanged. Checked with websockets 11.0, 12.0, 13.0, 13.1, 14.0, 14.2, 15.0, 15.0.1, 16.0 and 17.1.send(),sendall(),recv(),recv_into()andshutdown()match thessl.SSLSocketsignatures on every version from 3.8 to 3.14._tls.pyimports and builds its context on 3.8.20; the SDK as a whole already needs 3.9 at import time, and that is unchanged.http://or SOCKS proxy, TLS to the server is still set up from this context, so the fix applies.https://proxies behave as before: websockets needs an explicitproxy_sslfor them, which the SDK does not pass.SSLSocketand the fix applies. With gevent patched afterwards, the context keeps gevent's socket class and connects as onmaster. eventlet's green context andtruststore.inject_into_ssl()build their own sockets, so reads on them are not held and connects behave as onmaster.Testing
tox(the repo's matrix:py{39,310,311,312}-latest,py311-httpx0.22,py311-httpx0.24,py311-pydantic1.10,py311-websockets11.0): all 8 environments pass, 733 tests each._tls.pyline coverage is 95%.pytest tests/uniton Python 3.12.3 with websockets 11.0.3, 12.0, 13.1, 14.2, 15.0.1 and 16.0, on Python 3.9.25 with websockets 13.0, 14.0 and 15.0, and on Python 3.13.15 and 3.14.7 with websockets 17.1: 733 passed in each.pytest -W error tests/unit/test_streaming_tls.pyon each of those websockets versions: no warnings.ruff format --check --config ./ruff.tomlandruff check --config ./ruff.toml(ruff 0.3.5), andmypy --follow-imports=silent --ignore-missing-imports(mypy 1.5.1, Python 3.9) on the changed files: clean.tests/unit/test_streaming_tls.pyuses theopensslCLI to make a throwaway self-signed certificate for127.0.0.1(and skips ifopensslis missing), and runs TLS 1.3 only local servers. It covers:CERT_REQUIREDandcheck_hostname, and an untrusted certificate fails verificationsendall()shutdown()releases a held read well before the wait bound, and a read proceeds once a shortened bound runs outwss://throughwebsocket_connect(), on_WriteFirstSSLSocketand TLS 1.3, withDeprecationWarningturned into an error_WriteFirstSSLSocketdoes not extend is left in place, with verification unchangedStreamingClient.connect()passes the context under the version-correct keyword alongsideadditional_headersandopen_timeoutforwss://, and no TLS argument forws://Each test fails when the behaviour it covers is removed (no read hold,
shutdown()not releasing, the wrong keyword on websockets 12 or 16, a context passed forws://, the socket class replaced unconditionally).Also checked by hand against a local TLS 1.3 websockets server with gevent 26.9.0, eventlet 0.41.2 and truststore 0.10.4, each patched before and after importing the SDK: every connect succeeds, with the socket classes described under Compatibility.
A further 120 connect cycles on the final branch head against the same endpoint as the table above: 0 retries, 0 failures, p50 25.9 ms, max 94.4 ms, all on
_WriteFirstSSLSocketover TLS 1.3.🤖 Generated with Claude Code
https://claude.ai/code/session_019UNrfUwp3uuUFEEbNvY3zH