Building a UDP Protocol with asyncio Datagram Endpoints¶
asyncio has no open_datagram_connection() to match open_connection(), and that absence is deliberate: there is no stream to read, because UDP has no stream. What it offers instead is loop.create_datagram_endpoint(), the callback-based transport/protocol API, and a set of obligations that TCP normally handles for you — retransmission, ordering, duplicate detection and flow control are now yours. That trade buys real things: a metrics emitter that never blocks on a dead collector, a discovery beacon that reaches a subnet in one send, a game or telemetry protocol where a late packet is worse than a lost one. This guide builds a UDP request/reply protocol on top of the datagram endpoint, verified against a server that drops half the replies.
Prerequisites¶
- Python 3.11+. Everything here is standard library; the measurements are from a loopback run on Linux.
- The transport/protocol layer from Streams, Transports & Protocols, since datagram endpoints are the callback API, not the stream API.
- Futures from Future Objects & Callbacks, because correlating a reply with its request means resolving a future from a callback.
1. Bind an endpoint and answer datagrams¶
A datagram protocol implements up to four callbacks. create_datagram_endpoint returns the transport and the protocol instance once the socket is bound:
import asyncio
class EchoServer(asyncio.DatagramProtocol):
def connection_made(self, transport):
self.transport = transport # the only way to send
def datagram_received(self, data, addr):
self.transport.sendto(b"pong:" + data, addr) # addr identifies the peer
def error_received(self, exc):
print("error_received:", exc)
async def serve() -> None:
loop = asyncio.get_running_loop()
transport, protocol = await loop.create_datagram_endpoint(
EchoServer, local_addr=("127.0.0.1", 0)) # port 0: the OS picks one
print("bound to", transport.get_extra_info("sockname"))
try:
await asyncio.sleep(3600)
finally:
transport.close()
sendto(data, addr) is not a coroutine and never blocks; it hands the datagram to the kernel and returns. There is no drain() because there is no reliable buffer to drain into — if the send buffer is full the datagram is dropped, which is the transport behaving as designed.
A client is the same class with remote_addr instead of local_addr. That connects the socket, which has two effects worth knowing: sendto(data) may omit the address, and the socket will only accept datagrams from that peer. A round trip on loopback measured 0.23 ms.
Verify: the client receives b'pong:hi' from the server's sockname.
2. Handle the errors UDP does report¶
UDP is connectionless, but a connected socket still learns about some failures: when a datagram reaches a host with nothing bound to the port, the ICMP port-unreachable message surfaces on the next operation. asyncio delivers it to error_received:
client error_received: ConnectionRefusedError [Errno 111] Connection refused
That arrived within 300 ms of sending to a closed local port. On an unconnected socket — one created with local_addr only — the ICMP message cannot be attributed to a peer, so nothing is reported at all. This asymmetry is the single most surprising thing about UDP in asyncio: the same code is silent or noisy depending on whether you passed remote_addr.
Size limits arrive through the same callback. Sending payloads of increasing size to a bound peer:
| payload | result |
|---|---|
| 1,472 bytes | delivered, fits one Ethernet frame |
| 8,192 bytes | delivered on loopback |
| 65,508 bytes | OSError: [Errno 90] Message too long |
65,507 is the IPv4 maximum; anything above it is refused outright. Between the MTU and that ceiling the datagram is IP-fragmented, and a single lost fragment loses the whole datagram — so the practical guidance is to keep payloads under about 1,400 bytes.
Verify: sending to a port with no listener raises ConnectionRefusedError in error_received on a connected endpoint and nothing at all on an unconnected one.
3. Correlate replies with requests¶
Because datagrams can be lost, duplicated or reordered, every message needs an identifier and every reply must be matched to a pending request. The shape is a dictionary of futures keyed by request id — the same pattern as any other multiplexed protocol:
import struct
class RequestReply(asyncio.DatagramProtocol):
def __init__(self):
self._pending: dict[int, asyncio.Future] = {}
self._next = 0
self.unmatched = 0
def connection_made(self, transport):
self.transport = transport
def datagram_received(self, data, addr):
(rid,) = struct.unpack_from("!I", data)
future = self._pending.pop(rid, None)
if future is None or future.done():
self.unmatched += 1 # a reply to a retired attempt
return
future.set_result(data[4:])
def error_received(self, exc):
for future in self._pending.values(): # ICMP kills every outstanding request
if not future.done():
future.set_exception(exc)
The future is None or future.done() check is not defensive padding: after a retry, the original attempt's reply may still arrive, and resolving an already-resolved future raises InvalidStateError — see avoiding InvalidStateError on futures. Counting those late replies gives you a free network-quality metric.
Verify: a reply carrying an unknown id increments unmatched instead of raising.
4. Retry with a fresh id and a growing timeout¶
The request method allocates an id, registers a future, sends, and waits. Each attempt gets a new id, so a late reply to attempt one can never satisfy attempt two:
async def request(self, payload: bytes, *, attempts: int = 4,
timeout: float = 0.2) -> bytes:
loop = asyncio.get_running_loop()
for attempt in range(attempts):
self._next += 1
rid = self._next
future = loop.create_future()
self._pending[rid] = future
self.transport.sendto(struct.pack("!I", rid) + payload)
try:
return await asyncio.wait_for(future, timeout * (2 ** attempt))
except TimeoutError:
self._pending.pop(rid, None) # retire it before retrying
raise TimeoutError(f"no reply after {attempts} attempts")
Run against a server that discards 50% of replies, 50 requests produced 46 successes and 4 failures from 103 datagrams reaching the server — the doubling timeout absorbing the loss. Two details make this safe to copy: the pop in the except TimeoutError branch prevents the pending map from growing without bound, and the doubling means a genuinely unreachable peer is abandoned in 3 s rather than after 4 identical fast retries. Add jitter as described in retry and backoff strategies when many clients retry against the same server.
Retrying at all assumes the request is idempotent. It has to be: with UDP you cannot distinguish a lost request from a lost reply, so the server may execute a retried request twice.
Verify: with the lossy server, successes plus failures equal the request count and the pending map is empty at the end.
5. Keep the receive path non-blocking¶
datagram_received runs on the event loop, synchronously. Doing work there delays every other datagram and every other task in the process. The rule is the same as for any callback API: parse, dispatch, return.
def datagram_received(self, data, addr):
try:
self.inbox.put_nowait((data, addr)) # bounded queue, never awaits
except asyncio.QueueFull:
self.dropped += 1 # shedding, visible in metrics
Dropping on a full queue is the honest policy for UDP: the alternative — an unbounded queue — converts a transient burst into permanent memory growth, and the sender already assumes datagrams may vanish. A worker task drains the queue at whatever rate it can sustain, as in Async Queue Management.
The kernel has its own bounded buffer in front of yours. When the receive buffer overflows the datagrams are dropped before Python sees them, which shows up in netstat -su as "packet receive errors" rather than anywhere in your application. If that counter is climbing, raise SO_RCVBUF on the socket from transport.get_extra_info("socket") and make the drain loop faster.
Verify: under a burst larger than the queue, dropped rises while the loop's callback latency stays flat.
Verification¶
A UDP protocol is sound when:
- Every message is identified: replies are matched by id, not by arrival order.
- Retries are bounded and idempotent: attempts are capped, the pending map shrinks, and re-execution is harmless.
- Sizes are bounded: payloads stay under the path MTU, with a hard check against 65,507.
- Errors are wired:
error_receivedfails outstanding requests instead of leaving them to time out. - Receiving never blocks:
datagram_receivedonly enqueues, with a bounded queue and a drop counter.
Pitfalls & edge cases¶
- Expecting
error_receivedon an unconnected socket. Withoutremote_addrthere is no peer to attribute an ICMP error to, and nothing is reported. - Assuming one
sendtoequals onedatagram_received. It can be zero (lost), one, or more than one (duplicated by a retransmitting middlebox). sendtoon a closed transport. It is silently ignored rather than raising; checktransport.is_closing()if that matters.- Reusing request ids after a restart. A server that caches replies by id can answer a new request with a stale one; include a per-process nonce or start from a random id.
- Multicast and broadcast. Both need socket options set before binding — pass a pre-configured socket with the
sockargument rather thanlocal_addr. - Using UDP for bulk transfer. Once you have implemented retransmission, ordering and congestion control, you have written a worse TCP; reach for QUIC or TCP instead.
Frequently Asked Questions¶
How do I send and receive UDP packets with asyncio?
Call await loop.create_datagram_endpoint(ProtocolClass, local_addr=...) to bind, or remote_addr=... to connect to a peer. It returns a transport and your protocol instance. Send with transport.sendto(data, addr) — it is a plain method, not a coroutine — and receive in the protocol's datagram_received(data, addr) callback.
Why doesn't my asyncio UDP client see errors when the server is down?
Because the socket is unconnected. ICMP port-unreachable can only be attributed to a peer, so asyncio reports it through error_received only when the endpoint was created with remote_addr. Without it, sends to a dead port succeed silently and the only symptom is a missing reply.
What is the maximum size of a UDP datagram in Python?
The IPv4 limit is 65,507 bytes of payload; asking for more raises OSError [Errno 90] Message too long. Anything above the path MTU is fragmented at the IP layer, and losing one fragment loses the whole datagram, so keep payloads under roughly 1,400 bytes for datagrams that cross a real network.
How do I match UDP replies to requests in asyncio?
Put a request id in the message, keep a dict of id to asyncio.Future, and resolve the future in datagram_received. Use a new id for each retry so a late reply cannot satisfy a newer attempt, and ignore replies whose id is not pending — count them as a network-quality metric.
Should datagram_received do any work?
No. It runs synchronously on the event loop, so anything slow in it delays every other task. Parse the header, put the message on a bounded queue with put_nowait, and let worker tasks handle it. Dropping on a full queue is appropriate for UDP, where loss is already part of the contract.
Related¶
- Streams, Transports & Protocols — up to the topic overview for the transport layer.
- Adding TLS to asyncio streams — the connection-oriented counterpart to this page.
- Avoiding InvalidStateError on futures — the failure mode late replies cause.
- Network I/O & Protocol Handling — the section overview for protocol work.