httpx async requests to a .local hostname time out on macOS
TL;DR — On macOS,
httpx.AsyncClientrequests to a.local(mDNS/Bonjour) hostname raisehttpx.ConnectTimeouteven though the host is up andcurlworks instantly.getaddrinfoputs an IPv6 candidate first, and the async connect burns the whole connect timeout on that unroutable path before IPv4 gets a turn. Resolve the.localhost to IPv4 yourself, once, at the URL boundary — the system resolver handles mDNS fine — and rewrite the URL. Jump to the fix.
Our boat’s AI agent talks to a SignalK server on a
Raspberry Pi through a fleet of MCP servers — vessel data, logbook, tidal
currents, weather. All of them are thin Python services that reach the Pi over
HTTP with httpx.AsyncClient, and all of them address it by its mDNS name,
boat-server.local. One day, running the fleet from a Mac, every single tool
call timed out. Not one server — all of them, silently, the same way.
Problem
The client code is as plain as HTTP gets:
import httpx
client = httpx.AsyncClient(timeout=5.0)
resp = await client.get("http://boat-server.local:3000/signalk/v1/api/vessels/self")
And every call dies like this:
File ".../httpx/_transports/default.py", line 101, in map_httpcore_exceptions
raise mapped_exc(message) from exc
httpx.ConnectTimeout
Five seconds per request, every request, httpx.ConnectTimeout at the end of
it. From the agent’s point of view the whole tool surface is just… gone. It
answers questions with “I couldn’t reach the vessel data server” while the
server sits there, healthy, twenty feet away.
The part that sends you down the wrong path: everything else can reach it.
$ curl -s http://boat-server.local:3000/signalk/v1/api/vessels/self | head -c 60
{"name":"Naturali","mmsi":"368...
$ ping boat-server.local
64 bytes from 192.168.1.20: icmp_seq=0 ttl=64 time=4.2 ms
Instant. So the server is fine, mDNS resolution is fine, the network is fine — only the async Python path fails.
Diagnosis
Ask the resolver what it actually returns for the name:
$ python3 -c "import socket; \
[print(ai[0].name, ai[4][0]) for ai in \
socket.getaddrinfo('boat-server.local', 3000, type=socket.SOCK_STREAM)]"
AF_INET6 fe80::dea6:32ff:fe12:3456
AF_INET 192.168.1.20
There it is. On macOS, getaddrinfo for the .local name returns an IPv6
candidate first — here a link-local fe80:: address — and the IPv4 address
second. The IPv6 candidate is not usefully routable from the client (a
link-local address needs a scope/zone ID to be connectable, and the resolved
candidate doesn’t carry a working one), so connecting to it goes nowhere: no
refusal, no reset, just silence until the timeout.
Modern clients are supposed to shrug this off. That’s the whole point of
Happy Eyeballs (RFC 8305):
start with the first address family, and if it hasn’t connected within a couple
hundred milliseconds, race the other one in parallel. curl does exactly this,
which is why it never blinks. The async Python stack is supposed to do it too
— anyio implements Happy Eyeballs in connect_tcp()
with a 250 ms stagger.
What we observed instead: the async connect sat on the dead IPv6 attempt for
the full connect timeout before IPv4 ever got a useful turn — so with a 5 s
timeout, every request was ConnectTimeout, 100% reproducible. Whatever the
stagger did under the hood, the practical behaviour on macOS with a .local
name was “wait out IPv6, then die.”
And it hit every MCP server in the fleet at once, because they all shared
the same idiom: SIGNALK_URL=http://boat-server.local:3000 in the environment,
httpx.AsyncClient in the client. We found it because an agent benchmark run
suddenly scored terribly — the model was doing everything right and the tools
were timing out underneath it.
What we tried (and why it failed)
Blame the server. Restarted SignalK, checked its logs, hit the REST API
from three other machines. All fine — of course it was; curl from the same
Mac already proved that. Time spent anyway: too much.
Raise the timeout.
client = httpx.AsyncClient(timeout=30.0)
This just moves the pain. The connect still burns the IPv6 attempt first, so every cold request eats seconds before doing anything useful — and this stack answers voice queries with a hard latency budget of a few seconds total. A “fix” that spends the entire budget waiting on a dead address family is a failure with extra steps.
Hardcode the IP.
SIGNALK_URL=http://192.168.1.20:3000
Works instantly — which confirms the diagnosis: connect to the IPv4 address and
there’s nothing wrong at all. But the Pi’s address comes from DHCP, and the
entire reason the config says boat-server.local is so nobody maintains an IP
address by hand. Hardcoding it trades a bug for a chore.
Force IPv4 in the transport. httpx has a documented knob for exactly this:
transport = httpx.AsyncHTTPTransport(local_address="0.0.0.0")
client = httpx.AsyncClient(transport=transport)
Binding the local side to 0.0.0.0 restricts connections to IPv4, and it does
work. But it has to be threaded through every AsyncClient construction
site, in every service — including ones where the client is built deep inside a
helper. And it turns off IPv6 for all destinations that client touches, not
just the one broken .local name. We wanted the fix at the one place all the
services already share: the URL.
The fix
Resolve the .local host to its IPv4 address once, at the URL boundary,
and hand httpx a URL with the IP already in it. The synchronous system resolver
handles mDNS perfectly well — the problem was never resolution, it was which
candidate the async connect tried first.
import socket
from urllib.parse import urlsplit, urlunsplit
def resolve_local_host(base_url: str) -> str:
"""Resolve a .local (mDNS) host in base_url to its IPv4 address.
Non-.local hosts, empty input, and resolution failures are returned
unchanged so httpx still gets its normal shot.
"""
parts = urlsplit(base_url)
host = parts.hostname
if not host or not host.endswith(".local"):
return base_url
try:
infos = socket.getaddrinfo(host, parts.port, socket.AF_INET,
socket.SOCK_STREAM)
except (socket.gaierror, OSError):
return base_url
if not infos:
return base_url
ip = infos[0][4][0]
netloc = ip if parts.port is None else f"{ip}:{parts.port}"
return urlunsplit((parts.scheme, netloc, parts.path, parts.query,
parts.fragment))
Call it where the URL enters the process:
base_url = resolve_local_host(os.environ.get("SIGNALK_URL",
"http://boat-server.local:3000"))
client = httpx.AsyncClient(base_url=base_url, timeout=5.0)
Passing socket.AF_INET to getaddrinfo asks for IPv4 candidates only, so
there is no IPv6 address to trip over. Every request connects in milliseconds.
Two properties made this the right shape:
- It’s a pass-through. Non-
.localhosts come back untouched. Resolution failures come back untouched — httpx gets its normal shot and produces its normal error, instead of this helper inventing a new failure mode. - It’s at the boundary. One call where config becomes a client, instead of a transport flag threaded through every constructor.
One fix, five repos: when a patch becomes a library
The first version of that function was pasted inline into the SignalK MCP server, with a docstring and a small test file. Then the logbook server needed it. Then currents. Then weather. Same commit message four times:
fix(client): resolve .local mDNS host to IPv4 (macOS httpx async/IPv6 hang)
Copy-pasting a fix across a fleet is fine exactly once — it’s how you learn the
fix is actually cross-cutting. The fourth paste is the signal: this is a shared
library now. Ours is
naturali-mcp-netutil
— one function, stdlib-only, MIT, with the whole why in the README.
We deliberately did not publish it to PyPI. Consumers pull it as a uv git source pinned to a tag:
dependencies = ["naturali-mcp-netutil>=0.1.0"]
[tool.uv.sources]
naturali-mcp-netutil = { git = "https://github.com/sailingnaturali/naturali-mcp-netutil", tag = "v0.1.0" }
The reasoning: this package has exactly four consumers, all ours. A PyPI
release adds packaging ceremony, a public namespace entry, and an implied
support surface — for a 30-line function nobody outside the fleet should
depend on. The git source gets pinned in each consumer’s uv.lock, so builds
are just as reproducible, and if the helper ever grows a real audience,
publishing it properly later costs nothing. Publish when someone else needs
it, not when you do.
Why it matters / gotchas
- The failure is silent at the layer you’re watching. An MCP tool that times out doesn’t crash anything — the agent just reports it can’t get the data, which reads like a model problem or a server problem. If every tool backed by the same host degrades at once, suspect the network path before either.
- “curl works” localizes the bug better than any log. Same URL, same machine, different HTTP stack → the bug lives in the stack, not the server. That one console line should have saved the first hour.
getaddrinfoorder is the whole game. Async connect logic believes the resolver’s ordering. When the first candidate is unroutable-but-silent (no RST, just a black hole), timeouts get charged to whoever is first in line.- The fix outlived the problem. We later retired mDNS naming entirely —
the fleet now reaches the boat server by a stable DNS name on a private
tailnet, and
.localno longer appears in any config. The resolver stayed: because it passes every non-.localURL through unchanged, it costs nothing, and it still guards anyone who points a config at a Bonjour name. A fix that’s a pass-through survives its own obsolescence.
Close
This came out of building the AI ops layer for an all-electric charter catamaran — a stack of open-source MCP servers between the agent and the boat’s SignalK data. The resolver and the servers it guards are public: naturali-mcp-netutil, signalk-mcp, and the rest at github.com/sailingnaturali.
Related: launchd’s minimal PATH breaks MCP servers: uv command not found — the other way an environment silently kills an MCP fleet — and when MCP tools break, isolate the server before blaming the agent for the debugging discipline that finds this class of bug.