Skip to content

fix(streaming): hold sync client TLS reads until the handshake request is written - #265

Merged
aleks-mitov merged 2 commits into
masterfrom
aleks/sync-streaming-tls-write-first
Sep 28, 2026
Merged

aleks-mitov merged 2 commits into
masterfrom
aleks/sync-streaming-tls-write-first

Conversation

@aleks-mitov

Copy link
Copy Markdown
Contributor

Summary

The sync streaming client (RealTimeTranscriber, also exported as StreamingClient, in assemblyai.streaming.v3) opens its connection with websockets.sync.client.connect(). That function starts the connection's reader thread, which blocks in SSLSocket.recv(), before the calling thread writes the HTTP upgrade request with sendall() 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 the 101 Switching Protocols response. 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 larger connect_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.

Connect variant Failed handshakes
Raw socket, single thread 0 / 1800
Raw socket, a reader thread blocked in recv() while the request is written (no websockets involved) 4 / 1200
websockets 16.0 sync client 9 / 1800
TLS 1.3 with OP_NO_TICKET on the client context 9 / 1200
Client context capped at TLS 1.2 0 / 1200
websockets sync client with a socket class whose reads wait for the first write (this change) 0 / 2400

Reproduced 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() plus disconnect(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) This branch
Connects over 1.2 s 14 of 600 (2.3%) 0 of 600
Connect retries 14 0
Failed connects 0 0
p50 connect time 25.5 ms 25.2 ms
p99 connect time 1527 ms 79.9 ms
Max connect time 1530 ms 105.5 ms
TLS socket class / version SSLSocket, TLSv1.3 _WriteFirstSSLSocket, TLSv1.3

Every retry on master was a single retry logged as WebSocket connect attempt 1/3 failed (timed out while waiting for handshake response); retrying. Leaving the retried connects out, the master p99 was 75.1 ms. Median disconnect time was about 0.52 s for both.

Changes

  • New assemblyai/streaming/v3/_tls.py:
    • _WriteFirstSSLSocket, an ssl.SSLSocket subclass whose recv() and recv_into() wait until the first send() or sendall() has returned (whether or not it succeeded) or shutdown() 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 returns ssl.create_default_context() with sslsocket_class set 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 _WriteFirstSSLSocket does not extend (for example when gevent patched ssl after the SDK was imported), the context keeps its own class.
  • assemblyai/streaming/v3/client.py: websocket_connect() now wraps websockets.sync.client.connect() and passes that context for wss:// URIs. Nothing is passed for ws://, where websockets rejects a TLS argument.
  • Version bumped to 1.6.2.
  • New tests in tests/unit/test_streaming_tls.py.

Compatibility

  • websockets: the sync connect() TLS parameter is ssl_context in 11.x and 12.x and ssl from 13.0 on. 13.0 and later still accept ssl_context but emit a DeprecationWarning. The client picks the name from the installed connect() signature, so no warning is raised. additional_headers is 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.
  • Python: the full suite passes on 3.9 to 3.14. The overridden send(), sendall(), recv(), recv_into() and shutdown() match the ssl.SSLSocket signatures on every version from 3.8 to 3.14. _tls.py imports and builds its context on 3.8.20; the SDK as a whole already needs 3.9 at import time, and that is unchanged.
  • Proxies (websockets 15 and later read proxy settings from the environment): through an 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 explicit proxy_ssl for them, which the SDK does not pass.
  • Green threads and replacement TLS stacks: with gevent patched before the SDK is imported, the class extends gevent's SSLSocket and the fix applies. With gevent patched afterwards, the context keeps gevent's socket class and connects as on master. eventlet's green context and truststore.inject_into_ssl() build their own sockets, so reads on them are not held and connects behave as on master.
  • asyncio client: untouched.

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.py line coverage is 95%.
  • pytest tests/unit on 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.py on each of those websockets versions: no warnings.
  • ruff format --check --config ./ruff.toml and ruff check --config ./ruff.toml (ruff 0.3.5), and mypy --follow-imports=silent --ignore-missing-imports (mypy 1.5.1, Python 3.9) on the changed files: clean.

tests/unit/test_streaming_tls.py uses the openssl CLI to make a throwaway self-signed certificate for 127.0.0.1 (and skips if openssl is missing), and runs TLS 1.3 only local servers. It covers:

  • the context keeps CERT_REQUIRED and check_hostname, and an untrusted certificate fails verification
  • a read is held while the server's data is already available, then proceeds after the first sendall()
  • shutdown() releases a held read well before the wait bound, and a read proceeds once a shortened bound runs out
  • a websockets echo handshake over wss:// through websocket_connect(), on _WriteFirstSSLSocket and TLS 1.3, with DeprecationWarning turned into an error
  • a default socket class that _WriteFirstSSLSocket does not extend is left in place, with verification unchanged
  • StreamingClient.connect() passes the context under the version-correct keyword alongside additional_headers and open_timeout for wss://, and no TLS argument for ws://

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 for ws://, 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 _WriteFirstSSLSocket over TLS 1.3.

🤖 Generated with Claude Code

https://claude.ai/code/session_019UNrfUwp3uuUFEEbNvY3zH

aleks-mitov and others added 2 commits September 27, 2026 12:10
… 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
@aleks-mitov aleks-mitov self-assigned this Sep 28, 2026
@aleks-mitov
aleks-mitov marked this pull request as ready for review September 28, 2026 15:15
@aleks-mitov
aleks-mitov merged commit 722cce9 into master Sep 28, 2026
8 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.

2 participants