The IPv6 ghost in the sandbox
An actor worked locally. It worked in CI. It failed in Apify's cloud sandbox with ECONNREFUSED on an HTTPS request to a public API. The error made no sense — the host was reachable, the cert was valid, the port was 443. But the connection kept getting refused.
I added logging. The resolved IP was an IPv6 address. The target API doesn't support IPv6.
What happened
Apify's container runtime uses dual-stack networking. When a hostname has both A and AAAA records, the sandbox resolves both. Node's fetch (and undici, and axios) will try IPv6 first if it's available. The API server only listens on IPv4. Connection refused. Retries don't help — they just hit IPv6 again.
Locally, my ISP doesn't give me IPv6, so the lookup returns only A records. In CI, GitHub Actions runners are IPv4-only. The sandbox is the only place where dual-stack exists. That's why I couldn't reproduce it.
The fix
Force IPv4 in the HTTP client. For undici (Node 18+ native fetch):
const agent = new undici.Agent({
connect: { family: 4 } // force IPv4
});
const res = await fetch(url, { dispatcher: agent });
For axios:
const res = await axios.get(url, {
family: 4, // or: resolver: (host) => dns.resolve4(host)
});
For plain fetch on Node 18+, there's no built-in flag yet. The workaround is a custom Agent or dns.setDefaultResultOrder('ipv4first') at process start (affects everything).
The pattern: environment assumptions leak
This isn't an Apify bug. It's a class of bug where local + CI environments share an assumption (IPv4-only) that the production runtime violates (dual-stack). Other examples:
- DNS search domains —
service.internalresolves in K8s, fails locally - Ephemeral port ranges — local kernel allows 32768-60999, container restricts to 49152-65535
- File descriptor limits —
ulimit -ndiffers, breaks connection pooling - /etc/hosts — injected entries in prod, clean locally
The habit that catches this
Run a real cloud test run for every deploy, not just "it builds." The two-minute verification against the actual runtime environment catches IPv6, DNS, memory limits, and the occasional API that behaves differently from a datacenter IP. It's the only way to know the code actually works where it lives.
I added family: 4 to my shared HTTP client wrapper. Next actor inherits the fix for free.