Opening TCP Connections with AnyIO¶
AnyIO's networking API — connect_tcp, create_tcp_listener and byte streams with send and receive — gives one TCP implementation for asyncio and trio, with TLS and cancellation built in. Its details differ from asyncio's streams in ways that matter for correctness and speed. Measured with AnyIO 4.15 on Python 3.14: a connection attempt to an unroutable address had no timeout of its own and was stopped by fail_after(1) at 1.00 s on both backends. TLS with connect_tcp(..., ssl_context=..., tls_hostname=...) connected and completed a TLS 1.3 handshake in 3.3–4.0 ms locally and rejected a wrong hostname with SSLCertVerificationError. A line-echo protocol built on BufferedByteReceiveStream.receive_until(b"\n", 1024) accepted a 5,000-byte line that arrived in one piece — max_bytes bounds buffering, not line length — and raised DelimiterNotFound only when the newline arrived later. Pipelining 100,000 small lines ran at 33,700/s on asyncio and 39,800/s on trio with one send per line, against 189,000/s for plain asyncio streams, which buffer writes; batching 100 lines per send brought AnyIO to 66,000–76,000/s. This guide builds a client and server with those properties in mind.
Prerequisites¶
- AnyIO 4, and trio if you want the second backend.
- Cancel scopes, from using AnyIO cancel scopes and move_on_after.
- The topic overview, AnyIO & Trio Interop.
1. Connect with an explicit deadline¶
anyio.connect_tcp resolves the host, tries its addresses — with happy eyeballs when there are several — and returns a SocketStream. It does not impose a timeout; a SYN sent into a black hole is retried by the kernel for minutes. Put a deadline around it:
import anyio
async def open_conn(host: str, port: int) -> anyio.abc.SocketStream:
with anyio.fail_after(5): # TimeoutError if not connected in 5 s
return await anyio.connect_tcp(host, port)
Measured with an unroutable address, 10.255.255.1:80: fail_after(1) raised TimeoutError after 1.00 s on both asyncio and trio. Use fail_after when a missed deadline is an error and move_on_after when the caller can carry on without the connection. The deadline covers name resolution too; a slow resolver is covered in resolving DNS without blocking the executor. Close streams with async with, which also closes them when the task is cancelled.
Verify: connecting to an unreachable address fails at your deadline, not at the kernel's.
2. Add TLS with a context you control¶
Pass an ssl.SSLContext and the hostname to verify. AnyIO wraps the TCP stream in a TLSStream after connecting:
import ssl
CTX = ssl.create_default_context() # system CAs, hostname checking on
async def open_tls(host: str, port: int):
with anyio.fail_after(5):
return await anyio.connect_tcp(host, port, ssl_context=CTX, tls_hostname=host)
Measured against a local AnyIO TLS listener with a certificate issued by a test CA: connect plus TLS 1.3 handshake in 4.0 ms on asyncio and 3.3 ms on trio; a connection with tls_hostname="wrong.example" raised SSLCertVerificationError: Hostname mismatch. Create the context once and reuse it — building a default context loads the system's CA store each time, a cost measured in reusing SSL contexts in async clients. For protocols where the peer may close the TCP connection without a TLS close-notify, tls_standard_compatible=False avoids spurious errors at end of stream; keep it True where truncation attacks matter.
Verify: a server with a certificate for a different name is rejected.
3. Frame messages, and check their length yourself¶
A TCP stream has no message boundaries; receive() returns whatever bytes have arrived. For line- or delimiter-based protocols, wrap the stream in BufferedByteReceiveStream:
from anyio.streams.buffered import BufferedByteReceiveStream
MAX_LINE = 1024
class ProtocolError(Exception):
pass
async def read_lines(stream):
buf = BufferedByteReceiveStream(stream)
while True:
try:
line = await buf.receive_until(b"\n", MAX_LINE)
except anyio.DelimiterNotFound:
raise ProtocolError("line too long") from None
except (anyio.IncompleteRead, anyio.EndOfStream):
return
if len(line) > MAX_LINE: # max_bytes did not stop this one
raise ProtocolError("line too long")
yield line
The explicit length check is needed. Measured with max_bytes=1024: a 5,000-byte line sent in one piece with its newline was returned whole, because receive_until checks the limit only while the delimiter is still missing from the buffer; the same line with the newline sent 50 ms later raised DelimiterNotFound. So max_bytes limits how much is buffered while waiting, not how long a returned line may be, and a protocol that relies on it for input validation accepts oversized messages whenever they arrive in one segment. For length-prefixed protocols, receive_exactly(n) reads a fixed number of bytes — validate n against a maximum before calling it.
Verify: a line longer than the limit is rejected whether it arrives in one segment or several.
4. Serve connections in a task group¶
create_tcp_listener binds and listens; listener.serve(handler) accepts connections and runs the handler for each in a task group, so a crash in one handler does not go unnoticed and cancelling the server cancels every connection:
async def handle(client: anyio.abc.SocketStream) -> None:
async with client:
try:
async for line in read_lines(client):
await client.send(line + b"\n")
except ProtocolError:
await client.send(b"ERR line too long\n")
async def main():
listener = await anyio.create_tcp_listener(local_host="0.0.0.0", local_port=7000)
await listener.serve(handle)
anyio.run(main) # or anyio.run(main, backend="trio")
The same code ran on both backends in these tests. Exceptions in a handler propagate out of serve and stop the listener unless the handler catches them, which is the structured-concurrency default; catch per-connection errors inside the handler, as above, so one bad client cannot take the server down. To learn the port when binding to port 0, read listener.extra(anyio.abc.SocketAttribute.local_port), and to wait until a server is listening before using it, use task_group.start as in waiting for readiness with AnyIO task_group.start.
Verify: a client that sends an invalid message gets an error and is disconnected, while other clients are unaffected.
5. Batch small writes¶
Each await stream.send(data) on an AnyIO stream is a separate write with its own flow-control wait, whereas asyncio's StreamWriter.write appends to a buffer and drain() only waits when the buffer is full. For protocols that send many tiny messages, that difference dominates:
async def send_lines(stream, lines, batch=100):
pending = []
for line in lines:
pending.append(line)
if len(pending) == batch:
await stream.send(b"".join(pending))
pending.clear()
if pending:
await stream.send(b"".join(pending))
Measured with 100,000 pipelined 8–12-byte lines echoed back: one send per line, 33,700 round trips/s on asyncio and 39,800 on trio; plain asyncio streams with write and drain, 189,000/s; AnyIO with the client batching 100 lines per send, 76,000/s on asyncio and 66,000/s on trio, with the server still sending one reply per line. For request/response protocols with one message per round trip, the per-send cost is invisible next to network latency; it matters for streaming many small records, where joining them into larger sends is the fix.
Verify: for your message sizes, throughput with batched sends is measured, and the batch size does not add unacceptable latency.
Verification¶
AnyIO TCP code is robust when:
- Every connect has a deadline from
fail_afterormove_on_after. - TLS uses a shared context and verifies the hostname.
- Message length is checked after framing, not left to
max_bytes. - Handlers catch per-connection errors, and small writes are batched where throughput matters.
Diagnostic Hook: count connections rejected for oversized messages and log the size. Rejections that appear only after a client changes its write pattern — larger TCP segments, a different library — point at framing code that relied on how bytes happened to arrive, exactly the max_bytes behaviour measured here.
Pitfalls & edge cases¶
- Connecting without a deadline. The kernel retries a dead host for minutes.
- Trusting
max_bytesas a line limit. Measured: a 5,000-byte line passed a 1,024 limit. - One
sendper tiny message. Measured: 33,700/s against 76,000/s batched. - Uncaught handler errors. They stop
listener.servefor everyone.
Frequently Asked Questions¶
How do I open a TCP connection with AnyIO?
await anyio.connect_tcp(host, port) returns a SocketStream; wrap it in fail_after for a deadline and async with to close it. Pass ssl_context and tls_hostname for TLS.
Does anyio.connect_tcp have a timeout?
No. A connection to an unroutable address was stopped only by the surrounding fail_after(1), at 1.00 s, on both asyncio and trio.
Does receive_until's max_bytes limit line length?
No: it limits buffering while the delimiter is missing. A 5,000-byte line arriving in one piece was returned with max_bytes=1024; check len(line) yourself.
Is AnyIO slower than asyncio streams?
For many tiny sends, yes: 33,700 line round trips/s against 189,000 with asyncio's buffered writer in testing. Batching 100 lines per send raised AnyIO to 66,000 to 76,000/s.
Related¶
- AnyIO & Trio Interop — up to the topic overview.
- Running subprocesses with AnyIO — the same scope-owned lifetimes for processes.
- Asyncio Fundamentals & Event Loop Architecture — the section overview.