Skip to content

Connecting with Happy Eyeballs in asyncio

A hostname usually resolves to several addresses — IPv6 and IPv4, several IPv4 addresses behind a load balancer — and a client must pick one. The classic algorithm tries them in order and moves to the next only when a connect fails. If the first address is unreachable in the worst way — packets silently dropped rather than refused — "fails" means the kernel's SYN retries time out, which on Linux with the default tcp_syn_retries = 6 takes about two minutes. Happy Eyeballs (RFC 8305) starts the next attempt after a short delay instead, and keeps whichever connects first. Measured on Python 3.14 with a resolver that returned a black-holed address followed by a working one: asyncio.open_connection with default settings was still connecting after 10 s, when the test gave up; with happy_eyeballs_delay=0.25 it connected in 0.25 s. aiohttp's default connector also connected in 0.25 s, and with Happy Eyeballs disabled it timed out after 10.58 s. httpx, through anyio, got its response in 0.33 s. This guide makes every client connect around dead addresses.

Prerequisites

1. Reproduce a dead first address

To test connection logic deterministically, make the resolver return an unreachable address first. 10.255.255.1 is a private address with no route on most machines, so SYN packets to it vanish:

import asyncio
import socket

BLACKHOLE = "10.255.255.1"


async def main() -> None:
    server = await asyncio.start_server(handler, "127.0.0.1", 0)
    port = server.sockets[0].getsockname()[1]
    loop = asyncio.get_running_loop()
    real = loop.getaddrinfo

    async def fake_getaddrinfo(host, p, *args, **kwargs):
        if host in ("dual.test", b"dual.test"):           # some clients pass bytes
            return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (BLACKHOLE, p)),
                    (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", p))]
        return await real(host, p, *args, **kwargs)

    loop.getaddrinfo = fake_getaddrinfo

The real-world equivalents are common: an IPv6 address on a network with broken IPv6 routing, a DNS record pointing at a decommissioned host, one dead backend among several A records. Each produces silence, not a refusal, and a client that waits for the silence to time out appears hung. Note the bytes check: anyio passes the hostname already IDNA-encoded, which is easy to miss when patching resolution in tests.

Verify: a plain socket.create_connection((BLACKHOLE, 80), timeout=3) raises TimeoutError after three seconds, confirming the address black-holes rather than refuses.

Time to connect when the first address is dead 5 horizontal bars comparing open_connection, default with the others. Time to connect when the first address is dead open_connection, default >10 s (gave up) aiohttp, happy_eyeballs_delay=None timed out at 10.58 s httpx (anyio) 0.33 s aiohttp default connector 0.25 s open_connection(happy_eyeballs_delay=0.25) 0.25 s Python 3.14; first address 10.255.255.1 (black-holed), second 127.0.0.1. Without a test limit the kernel's SYN retries take about two minutes. The delay, not the timeout, decides how quickly a dead address is skipped.

2. Enable Happy Eyeballs on asyncio connections

asyncio.open_connection and loop.create_connection accept happy_eyeballs_delay; setting it switches from sequential attempts to staggered, racing ones:

reader, writer = await asyncio.open_connection(
    host, port,
    happy_eyeballs_delay=0.25,      # start the next address after 250 ms
    interleave=1,                   # alternate address families: v6, v4, v6, ...
)

Measured: connected to 127.0.0.1 in 0.25 s — exactly one delay after the first, dead attempt started. Without the parameter, asyncio tried the black-holed address alone and was still waiting ten seconds later. The losing attempt is cancelled once a winner connects, so the cost of Happy Eyeballs is at most a few extra SYNs. RFC 8305 recommends 250 ms; shorter delays race more often on healthy networks, longer ones make failures slower. interleave alternates address families, so a broken IPv6 path does not make every IPv4 address wait behind every IPv6 one.

Verify: every open_connection and create_connection call to a hostname in your code passes happy_eyeballs_delay.

3. Know your HTTP client's default

HTTP clients make their own connections and their own choices:

import aiohttp
import httpx

# aiohttp: Happy Eyeballs on by default (via the aiohappyeyeballs package)
connector = aiohttp.TCPConnector(happy_eyeballs_delay=0.25)   # the default value
session = aiohttp.ClientSession(connector=connector)

# httpx: connects through anyio, which races addresses itself
client = httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=3.0))

Measured against the same dead-first resolver: aiohttp's default connector answered in 0.25 s; aiohttp with happy_eyeballs_delay=None timed out at 10.58 s, the session's total timeout; httpx answered in 0.33 s. Both clients do the right thing by default. The failure mode is code that bypasses them — raw asyncio.open_connection in a protocol client, a database driver, a custom TCP health check — which uses the sequential default unless told otherwise. Database drivers are a frequent case: check whether yours exposes the option, or connect through addresses you resolved and ordered yourself.

Verify: for each library that opens connections in your service, you know whether it races addresses; a test with a dead-first resolver confirms it.

Sequential attempts versus Happy Eyeballs 3 lanes over time. Sequential attempts versus Happy Eyeballs sequential: 10.255.255.1 SYN... no answer (kernel gives up ~127 s) happy eyeballs: 10.255.255.1 SYN... happy eyeballs: 127.0.0.1 connect time (sequential bar not to scale) → Measured: Happy Eyeballs connected at 0.25 s; sequential was still waiting at 10 s.

4. Bound connects with a timeout as well

Happy Eyeballs skips dead addresses when a live one exists. When every address is dead, a connect still waits for the kernel unless the application sets a deadline:

async def connect(host: str, port: int) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
    async with asyncio.timeout(3.0):                     # whole connect, all addresses
        return await asyncio.open_connection(host, port, happy_eyeballs_delay=0.25)

With tcp_syn_retries = 6, an unanswered connect on Linux takes about 127 seconds before the kernel reports failure — far beyond any request deadline. A connect timeout of a few seconds, separate from the read timeout, turns an unreachable host into a fast error that retries and circuit breakers can act on; the layering is covered in setting per-attempt and total timeouts for retries. Lowering tcp_syn_retries system-wide is possible but affects every program on the host; an application-level timeout is more precise.

Verify: connecting to a host whose only address is black-holed fails within the connect timeout.

5. Order and cache addresses deliberately

For long-lived services talking to a fixed set of backends, the addresses themselves are worth managing: cache resolutions, remember which addresses failed, and put healthy ones first:

class AddressBook:
    def __init__(self, ttl: float = 30.0) -> None:
        self.ttl = ttl
        self.cache: dict[tuple[str, int], tuple[float, list[str]]] = {}
        self.bad_until: dict[str, float] = {}

    async def addresses(self, host: str, port: int) -> list[str]:
        loop = asyncio.get_running_loop()
        now = loop.time()
        hit = self.cache.get((host, port))
        if hit is None or hit[0] < now:
            infos = await loop.getaddrinfo(host, port, type=socket.SOCK_STREAM)
            hit = (now + self.ttl, [info[4][0] for info in infos])
            self.cache[(host, port)] = hit
        healthy = [a for a in hit[1] if self.bad_until.get(a, 0) < now]
        return healthy or hit[1]

    def mark_bad(self, addr: str, seconds: float = 30.0) -> None:
        self.bad_until[addr] = asyncio.get_running_loop().time() + seconds

Racing still protects against a dead address that has not been marked yet; the address book stops the service from rediscovering the same dead address on every connection. Respect the record's TTL in production rather than a fixed value — the DNS caching guide covers the trade-off between stale addresses and lookup cost.

Verify: after one failed connect to an address, subsequent connections within the penalty window try the healthy addresses first.

How should this connection be made? A decision on How is the connection opened with 4 outcomes. How should this connection be made? How is the connection opened? asyncio.open_connection / create_connection happy_eyeballs_delay=0.25 0.25 s, not >10 s aiohttp or httpx defaults already race do not disable any connect asyncio.timeout of a few seconds kernel waits ~127 s fixed backends, long-lived cache + mark failed addresses skip known-dead Racing handles one dead address; a timeout handles all of them.

Verification

Connections route around dead addresses when:

  • Every raw asyncio connect to a hostname passes happy_eyeballs_delay.
  • HTTP clients keep their racing defaults, confirmed by a dead-first resolver test.
  • Every connect has a timeout of a few seconds, independent of read timeouts.
  • Known-bad addresses are deprioritized in long-lived clients.

Diagnostic Hook: record connect time per resolved address, not just per hostname. A hostname whose connect p99 sits at exactly the Happy Eyeballs delay has a dead first address being skipped on every connection — harmless to latency, but worth fixing at the DNS record.

Pitfalls & edge cases

  • Raw open_connection defaults. Measured: still connecting after 10 s with a dead first address.
  • Disabling the client's racing. Measured: aiohttp timed out at 10.58 s.
  • No connect timeout. When all addresses are dead, the kernel waits about two minutes.
  • Patching resolvers in tests. Some clients pass bytes hostnames.

Frequently Asked Questions

What is happy eyeballs in Python asyncio?

A connection strategy (RFC 8305) that starts the next resolved address after a short delay instead of waiting for the current one to fail. Enable it with open_connection(..., happy_eyeballs_delay=0.25); in testing it connected around a dead first address in 0.25 s.

Why does my asyncio connection hang for two minutes?

The first resolved address drops packets instead of refusing them, and without happy eyeballs or a timeout the client waits for the kernel's SYN retries — about 127 s on Linux with tcp_syn_retries = 6.

Does aiohttp use happy eyeballs?

Yes, by default (happy_eyeballs_delay=0.25, via aiohappyeyeballs): it connected around a dead address in 0.25 s, and with the feature disabled it timed out.

Does httpx use happy eyeballs?

httpx connects through anyio, which races resolved addresses; in testing it got its response in 0.33 s with a dead first address.