Module 3 · Lesson 1
DNS Programming Interfaces and Libraries
⏱ 45 min
getaddrinfo() is the interface your OS exposes. It's also a blocking call that ignores TTLs, conflates resolution with querying, and has no async API. Here's what to use instead.
DNS Programming Interfaces and Libraries
Every application you've written does DNS. Your HTTP client does it. Your database driver does it. Your gRPC stub does it. And almost all of them go through the same system call: getaddrinfo().
That's fine for simple cases. But getaddrinfo() is a 1994 POSIX API that blocks the calling thread, ignores TTLs, can't query specific record types, and gives you exactly one answer when you might want several. The moment your application needs more than "give me an IP for this hostname," you need to understand what's underneath.
What getaddrinfo() Actually Does
When you call getaddrinfo("api.example.com", "443", ...) in any language, the OS resolver runs through the /etc/nsswitch.conf lookup order: first /etc/hosts, then DNS. It respects the system's configured DNS servers in /etc/resolv.conf, caches nothing itself (though the system resolver might), and blocks until it has a result or times out.
The critical point: getaddrinfo() resolves a name to addresses. It doesn't query DNS. That distinction matters when you need control.
Here's what "resolve" means in practice:
import socket
import time
# This is what most applications do
start = time.time()
addrs = socket.getaddrinfo("api.example.com", 443, type=socket.SOCK_STREAM)
elapsed = time.time() - start
# addrs is a list of (family, type, proto, canonname, sockaddr) tuples
# You get what the OS resolver gives you — one or more IPs
# You have no idea what the TTL was
# You have no idea if this was cached or a fresh lookup
# The call blocked your thread for {elapsed:.3f} seconds
print(f"Resolution took {elapsed*1000:.1f}ms")
for addr in addrs:
print(addr[4]) # (ip, port)
That last point is worth sitting with. You don't know if the call took 1ms (cache hit) or 300ms (cold recursive lookup across the internet). You have no visibility, no control.
The Blocking Problem
In Python with threads: every thread that calls getaddrinfo() blocks until DNS responds. If you're making 100 concurrent requests to different hostnames, you might have 100 threads blocked in DNS. Python's asyncio provides loop.getaddrinfo() which offloads to a thread pool — it's not truly async, it just moves the blocking to a different thread.
import asyncio
import socket
async def resolve_async(hostname: str, port: int):
loop = asyncio.get_running_loop()
# This still blocks a thread pool thread — just not your event loop thread
addrs = await loop.getaddrinfo(
hostname, port,
family=socket.AF_INET,
type=socket.SOCK_STREAM
)
return addrs[0][4][0] # First IPv4 address
async def main():
ip = await resolve_async("api.example.com", 443)
print(ip)
asyncio.run(main())
For truly async DNS, you need a library that implements the DNS protocol itself and drives it via non-blocking sockets.
Python: dnspython
dnspython is the de facto Python DNS library. It speaks DNS directly, bypasses getaddrinfo(), and gives you full control over query types, servers, and response handling.
import dns.resolver
import dns.asyncresolver
# Synchronous: query a specific record type
resolver = dns.resolver.Resolver()
resolver.nameservers = ['8.8.8.8', '1.1.1.1'] # Use specific servers, not system default
resolver.timeout = 2.0
resolver.lifetime = 4.0
# Query A records
try:
answer = resolver.resolve('api.example.com', 'A')
for rdata in answer:
print(f"IP: {rdata.address}, TTL: {answer.ttl}")
except dns.resolver.NXDOMAIN:
print("Domain does not exist")
except dns.resolver.NoAnswer:
print("No A records found")
except dns.exception.Timeout:
print("DNS query timed out")
# Query SRV records — something getaddrinfo() can't do
answer = resolver.resolve('_http._tcp.myservice.internal', 'SRV')
for rdata in answer:
print(f"Priority: {rdata.priority}, Weight: {rdata.weight}")
print(f"Target: {rdata.target}, Port: {rdata.port}")
# Truly async with dnspython
import asyncio
import dns.asyncresolver
async def resolve_with_ttl(hostname: str) -> list[tuple[str, int]]:
resolver = dns.asyncresolver.Resolver()
answer = await resolver.resolve(hostname, 'A')
return [(rdata.address, answer.ttl) for rdata in answer]
async def main():
results = await resolve_with_ttl('api.example.com')
for ip, ttl in results:
print(f"{ip} (expires in {ttl}s)")
asyncio.run(main())
The TTL visibility is the key advantage. You can build a local cache that respects DNS TTLs, revalidating when they expire, rather than re-resolving on every request or holding stale data indefinitely.
Go: net Package vs miekg/dns
Go's standard net package wraps the OS resolver, similar to getaddrinfo(). For basic hostname resolution it's fine, but it has the same limitations: no TTL visibility, no control over query types beyond A/AAAA/CNAME.
package main
import (
"context"
"fmt"
"net"
"time"
)
func resolveWithStdlib(hostname string) ([]string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
// net.DefaultResolver uses getaddrinfo() on Linux unless CGO_ENABLED=0
// With CGO disabled, Go uses its own pure-Go DNS resolver (which IS async)
addrs, err := net.DefaultResolver.LookupHost(ctx, hostname)
if err != nil {
return nil, err
}
return addrs, nil
}
func main() {
addrs, err := resolveWithStdlib("api.example.com")
if err != nil {
fmt.Printf("Error: %v\n", err)
return
}
for _, addr := range addrs {
fmt.Println(addr)
}
}
Note: When CGO_ENABLED=0 (common in Docker builds), Go uses its own DNS resolver, which is genuinely async and respects GODEBUG=netdns=go. When CGO is enabled, it falls back to the C library's getaddrinfo().
For full DNS control, use miekg/dns:
package main
import (
"fmt"
"time"
"github.com/miekg/dns"
)
type DNSResult struct {
Address string
TTL uint32
}
func queryWithTTL(hostname string, server string) ([]DNSResult, error) {
c := new(dns.Client)
c.Timeout = 3 * time.Second
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(hostname), dns.TypeA)
m.RecursionDesired = true
r, _, err := c.Exchange(m, server+":53")
if err != nil {
return nil, fmt.Errorf("dns query failed: %w", err)
}
if r.Rcode != dns.RcodeSuccess {
return nil, fmt.Errorf("dns error: %s", dns.RcodeToString[r.Rcode])
}
var results []DNSResult
for _, ans := range r.Answer {
if a, ok := ans.(*dns.A); ok {
results = append(results, DNSResult{
Address: a.A.String(),
TTL: ans.Header().Ttl,
})
}
}
return results, nil
}
func main() {
results, err := queryWithTTL("api.example.com", "8.8.8.8")
if err != nil {
fmt.Printf("Error: %v\n", err)
return
}
for _, r := range results {
fmt.Printf("IP: %s, TTL: %ds\n", r.Address, r.TTL)
}
}
Connection Pooling and DNS
Here's a subtlety that catches people: HTTP connection pools hold connections to IPs, not hostnames. If your DNS changes (failover, deployment, TTL expiry) but your pool is holding connections to the old IPs, those connections stay alive until they fail.
In Go's net/http:
import (
"net"
"net/http"
"time"
)
// Default transport reuses connections — DNS changes don't affect existing connections
// To force re-resolution, you need to close idle connections periodically
transport := &http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
client := &http.Client{Transport: transport}
// If you need connections to pick up DNS changes within TTL windows:
// Call transport.CloseIdleConnections() periodically
// Or set IdleConnTimeout to match your expected DNS TTL
In Python's requests (and httpx), sessions keep connections alive through urllib3 connection pools. The same issue applies.
Key Takeaways
getaddrinfo()blocks the calling thread, returns no TTL information, and can't query non-A/AAAA record types- Python's
asyncio.loop.getaddrinfo()is async to the event loop but still blocks a thread pool thread dnspythongives you async DNS, TTL visibility, and any record type — use it when you need control- Go's
netpackage withCGO_ENABLED=0uses a genuine async Go DNS resolver; with CGO it usesgetaddrinfo() miekg/dnsgives you raw DNS control in Go, same as dnspython does in Python- Connection pools hold IPs, not hostnames. DNS TTL changes don't break existing connections unless you close them
Further Reading
- dnspython documentation
- miekg/dns README and examples
- RFC 3493 — Basic Socket Interface Extensions for IPv6 (getaddrinfo spec)
- Go net package source — read the CGO vs pure-Go resolver notes
Up Next
Now that you understand how DNS resolution works at the code level, lesson 02 covers how to build application architectures that use DNS intentionally — SRV records, service discovery, and DNS as a configuration layer.