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.

dnspythongoprogramminggetaddrinfodnspythonmiekg

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
  • dnspython gives you async DNS, TTL visibility, and any record type — use it when you need control
  • Go's net package with CGO_ENABLED=0 uses a genuine async Go DNS resolver; with CGO it uses getaddrinfo()
  • miekg/dns gives 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

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.