A DNS-Caching Dialer for http.Transport in Go

Following one HTTP request through the nurago dnscache dial path: ASCII-only host folding, single-flight caching, canonical dedup, family interleaving, rotation, and the attempt loop.


Go’s standard http.Transport resolves the destination host on every new connection and does not cache the result. For a client that talks to the same handful of hosts thousands of times a minute, that is a steady drip of Domain Name System (DNS) queries, each adding latency to connection setup and load on the resolver. The dnscache package in nurago sits in that gap: a bounded, concurrency-safe DNS cache with a DialContext you can drop straight into a transport.

dc := dnscache.New(nil, 1024, time.Minute) // nil resolver: use net.Resolver

client := &http.Client{
    Transport: &http.Transport{
        DialContext: dc.DialContext,
    },
}

That is the whole integration (there is also a plain LookupHost for code that wants resolution without dialling). The interesting part is what happens inside. Caching a lookup is the easy half; the dialer, which must decide which resolved address to try first, is where a wrong choice quietly breaks connectivity on dual-stack networks. So rather than list features, let us follow one request, client.Get("https://api.Example.com/v1/things"), through the dial path, stage by stage.


Stage 1: normalising the hostname

The transport hands DialContext the string "api.Example.com:443". After splitting off the port, the host becomes a cache key, and DNS names have equivalent spellings: name matching is case-insensitive (RFC 4343), and api.example.com. with a trailing dot is the same host in its fully qualified form. If each variant got its own entry, the cache would fragment and the hit rate would quietly drop. So normalizeHost strips the trailing dot (leaving the DNS root "." alone) and folds the case:

func asciiLower(host string) string {
    var b []byte

    for i := range len(host) {
        c := host[i]
        if c < 'A' || c > 'Z' {
            continue
        }

        if b == nil {
            b = []byte(host)
        }

        b[i] = c + ('a' - 'A')
    }

    if b == nil {
        return host
    }

    return string(b)
}

Why not strings.ToLower? Not performance: the reason is correctness. RFC 4343 defines DNS case-insensitivity as ASCII-only, folding exactly A-Z to a-z, while strings.ToLower folds Unicode and would rewrite a non-ASCII label into a different DNS name before it ever reaches the resolver: Turkish İSTANBUL becomes istanbul (losing the dotted capital İ), fullwidth becomes , and U+212A KELVIN becomes a plain k. asciiLower touches only ASCII uppercase bytes and leaves everything else, including invalid UTF-8, byte-for-byte intact; the package’s tests pin all of these cases. As a side effect, an already-lower-case host (the common case) is returned without a copy.

Our api.Example.com is now api.example.com. One more shortcut lives here: a host that is already an IP literal bypasses the resolver and the cache entirely, mirroring net.Resolver.LookupHost.


Stage 2: cache hit, or one shared miss

The normalised key goes to the cache, which is nurago’s sfcache instantiated as sfcache.Cache[string, []string]. That layer already solves the hard parts: a bounded capacity, a single cache-wide time-to-live (TTL) so entries stay fresh (the authoritative DNS record TTLs are not consulted), and single-flight deduplication, so fifty goroutines that all miss on the same cold host trigger exactly one lookup and share its result. In resource terms the whole burst costs one lookup’s worth of resolver sockets instead of fifty, and a warm hit costs none, so a flood of concurrent requests for the same host does not become a flood of resolver connections and their ephemeral ports. Two options, WithStaleOnFailure and WithStaleIfError (the RFC 5861 variant), let the cache keep serving the last known good addresses for a bounded window when a refresh fails, which can turn a resolver outage into a non-event for hosts you talk to anyway. The details of that machinery, including what happens when the goroutine performing the shared lookup is cancelled, are sfcache’s own post ; here it is enough that our request gets back a list of address strings, from memory on a hit, from one shared resolver call on a miss.

That layering is the point of building small primitives: the caching edge cases were solved once, elsewhere, and this package does not re-solve them.


Stage 3: from strings to canonical candidates

A resolved host is not one address; it is a list, typically a mix of IPv6 (AAAA) and IPv4 (A) records, and a resolver can hand back the same destination under more than one spelling. The clearest case is an IPv4-mapped IPv6 address: ::ffff:192.0.2.1 and 192.0.2.1 are the same machine, and dialling both is wasted effort, plus a doubled timeout budget when it is down. Comparing raw strings would miss it. So every address is parsed once into a canonical netip.Addr:

addr, _ := netip.ParseAddr(ip)
cand := dialCandidate{raw: ip, addr: addr.Unmap()}

Unmap folds an IPv4-mapped IPv6 address down to its plain IPv4 form, and equivalent spellings such as 2001:DB8::1 and 2001:db8::1 parse to the same value, so duplicates collapse onto their first occurrence while the list keeps its resolver order. An entry that fails to parse is kept (deduplicated by raw string) so it can be reported as ErrInvalidIP later rather than silently dropped. From here on, family classification, filtering, and the eventual dial all work from the parsed form, so an address is not ordered as one family and dialled as another.


Stage 4: ordering, or why the dialer exists

Our request is on network tcp, so nothing is filtered; on a family-restricted network such as tcp4 or udp6, candidates of the other family would be dropped here rather than dialled and failed, and if none remained the call would end with ErrNoAddresses. What is left must be put in dial order, and the order is the whole game. Two naive orderings both fail in common situations:

  • All IPv6 first, then all IPv4. On a machine whose IPv6 path is broken (a misconfigured tunnel, a firewall dropping v6), every IPv6 attempt must fail before the first IPv4 address is even tried. The connection eventually succeeds, but the user experiences a wall of timeouts as “the app is slow”.
  • Ignore the resolver’s order entirely. The resolver returns addresses in a preference order for a reason (RFC 6724 address selection governs this), often reflecting which family actually works best from this host. Reshuffle it and you can systematically pick the worse path.

dnscache threads between them, borrowing the interleaving idea from Happy Eyeballs (RFC 8305) without the connection racing. It first picks the lead family with lead := isIPv6Addr(cands[0].addr), whichever family the resolver put first, then splits, optionally rotates, and merges:

first, second := splitByFamily(cands, lead)

if c.rotate {
    // One shared offset per dial: rotating each family group by its own
    // counter value would advance the counter twice per call and could
    // leave an even-sized group stuck on the same head.
    offset := c.nextDialOffset()
    first = rotateCandidates(first, offset)
    second = rotateCandidates(second, offset)
}

return interleave(first, second)

The lead family is whatever the resolver put first, so the very first attempt honours its preference; thereafter the families alternate (lead, other, lead, other, ...). A dead family costs one failed attempt before the other family gets its turn, not a whole run of them, and a working preferred family is still tried first.

The c.rotate branch is the opt-in WithAddressRotation: an atomic counter rotates the starting address on each dial, spreading connections across a host’s records instead of hammering the first one. Note that it rotates within each family, with one shared offset, so rotation can vary which IPv6 address leads but does not flip the lead family to IPv4. It is off by default, precisely because it overrides the resolver’s RFC 6724 ordering.


Stage 5: the attempt loop

The ordered candidates are dialled sequentially until one connects. Between attempts the loop checks the caller’s context, so a cancelled request stops immediately instead of grinding through the remaining addresses. Each attempt dials the canonical form (cand.addr.String(), joined with our port 443), and each can be individually bounded by WithDialTimeout (a Timeout on a dialer passed via WithDialer has the same per-attempt effect; when both are set, the shorter wins). That per-attempt bound is what makes the interleaving property concrete: one unresponsive address costs one bounded timeout, and the caller’s deadline survives for the rest of the list.

The first successful connection is returned to the transport, and our request finally gets its TCP connection, having cost zero DNS queries on a warm cache. If every attempt fails, the individual errors are aggregated with errors.Join, so the caller sees exactly which addresses failed and why, not just the last error.


None of these stages is individually dramatic. Together they are the difference between a DNS cache that speeds things up and one that occasionally strands a request behind a dead address family, a duplicated address, or a fragmented cache. The caching was the obvious feature; the dial path was the part that needed care.