Table of contents
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, and there is also a plain LookupHost for code that wants resolution without dialling.
Caching a lookup is the easy half. The dialer is the other half, and deciding which resolved address to try first is where a wrong choice breaks connectivity on dual-stack networks without ever raising an error. So take one request, client.Get("https://api.Example.com/v1/things"), and follow it down 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 drop for no visible reason. 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 A becomes a, 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, and every one of those cases has a test. 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
That normalised key goes to the cache, 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, and single-flight deduplication. The TTL is the cache’s own, incidentally, since authoritative DNS record TTLs are not consulted.
Deduplication means 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 one host does not become a flood of resolver connections and their ephemeral ports.
Two options, WithStaleOnFailure and WithStaleIfError (the RFC 5861 variant), 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 rest of that machinery, including what happens when the goroutine performing the shared lookup is cancelled, is covered in 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.
Caching edge cases were solved once, elsewhere. 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 get dropped here rather than dialled and failed, and if none remained the call would end with ErrNoAddresses.
What is left has to be put in dial order. 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)
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
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 through WithDialer has the same per-attempt effect, and when both are set the shorter wins.
That per-attempt bound is what makes the interleaving worth anything. One unresponsive address costs one bounded timeout, and the caller’s deadline survives for the rest of the list.
The first successful connection goes back to the transport, and our request finally has 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 which addresses failed and why instead of just the last one.
Skip any one of these stages and you still get a cache that mostly works. You also get a request stranded behind a dead address family, one destination dialled twice under two spellings, and a hit rate halved by case-variant keys. The caching was the obvious feature. The dial path took the work.