DEV Community

Taylor Wang
Taylor Wang

Posted on

The Model API Wasn't Down. My Free Server's DNS Resolver Was.

Last week my summarization worker started failing in a way that looked like a provider outage. The error was a classic fetch failed: getaddrinfo ENOTFOUND, yet the exact same request from the exact same machine succeeded seconds later. I spent an afternoon preparing a very confident bug report before I realized the real problem was sitting in my server's DNS resolver, not in anyone's API.

The setup that looked innocent

I run a small worker on a free server that polls a queue, sends each item to MonkeyCode's free model endpoint for a summary, and posts the result back. Disclosure: This article was prepared as part of MonkeyCode's product outreach. Nothing about the setup was exotic: Node.js 20, plain fetch, one outbound HTTPS call per job, and a keep-alive connection that stayed warm between jobs.

Locally, the same code ran for days without a single failure. On the free server it failed every twenty to forty minutes, almost always after a period of idle time. The pattern was so consistent that I assumed the provider was dropping idle connections, and my retry logic made things worse by hammering the endpoint the moment it recovered.

The symptom

The errors alternated between two shapes:

  • getaddrinfo ENOTFOUND api.example.com — the hostname did not resolve at all
  • connect ETIMEDOUT — the TCP connection never completed

Both appeared only inside the Node process. Neither ever appeared in a browser or in curl run from the same container.

The three theories I burned an afternoon on

  1. Rate limiting. I checked response codes and headers. There were no 429s, no Retry-After headers, nothing.
  2. Egress IP blocking. I ran the identical request from a different machine on the same network. It worked.
  3. TLS or certificate issues. No cert errors, no handshake failures in the logs.

The turning point was boring: during an outage window I ran curl -v https://api.example.com/health from inside the same container. It returned 200 OK in about 300 milliseconds. The Node process retried immediately afterward and failed again. Same machine, same network, same hostname, two different realities.

The clue: two resolvers, two realities

curl and Node do not resolve hostnames the same way, even on the same machine. curl is a short-lived process that usually benefits from a warm system cache, while a long-lived Node process performs fresh lookups whenever a keep-alive connection drops and needs to be re-established.

My worker's free server used the hosting provider's resolver, and that resolver was slow and occasionally flaky. The API hostname had a short TTL, so cached answers expired quickly. Every time the warm connection went stale, Node asked the resolver again, and one out of roughly fifty lookups took more than ten seconds or failed outright. curl got lucky because it hit the local cache; my worker got unlucky because it was the one doing the fresh lookup.

I confirmed the suspicion with a small probe that resolved the hostname fifty times in a row:

// dns-probe.mjs — measure resolver health before blaming the API
import dns from 'node:dns';
import { performance } from 'node:perf_hooks';

const HOST = 'api.example.com'; // replace with your real endpoint host
const RUNS = 50;

dns.setDefaultResultOrder('ipv4first');

let failures = 0;
const latencies = [];

for (let i = 0; i < RUNS; i++) {
  const start = performance.now();
  try {
    const addresses = await dns.promises.resolve4(HOST);
    latencies.push(performance.now() - start);
    console.log(`ok   ${i + 1}: ${addresses.join(', ')} (${latencies.at(-1).toFixed(1)} ms)`);
  } catch (err) {
    failures++;
    console.log(`fail ${i + 1}: ${err.code} (${(performance.now() - start).toFixed(1)} ms)`);
  }
  await new Promise(r => setTimeout(r, 1000));
}

latencies.sort((a, b) => a - b);
const p50 = latencies[Math.floor(latencies.length * 0.5)] ?? 0;
const p95 = latencies[Math.floor(latencies.length * 0.95)] ?? 0;

console.log(`\nfailures: ${failures}/${RUNS}`);
console.log(`p50: ${p50.toFixed(1)} ms`);
console.log(`p95: ${p95.toFixed(1)} ms`);
Enter fullscreen mode Exit fullscreen mode

The output was damning: a p50 around 40 milliseconds, a p95 over nine seconds, and several failures in fifty runs. The API was fine; the resolver was the unreliable dependency in the chain.

The fix: stop trusting the default resolver

I made four changes, in order of impact:

  1. Pin a resolver I could measure. dns.setServers(['1.1.1.1']) removes the flaky provider resolver from the path for dns.resolve* calls. If your host blocks outbound port 53, skip this and keep the provider resolver, but keep the probe running.
  2. Cache answers with respect for TTL. A tiny cache with a 60-second floor turned fifty lookups per hour into one. The catch is that fetch uses dns.lookup, which ignores dns.setServers, so I passed a custom lookup function to the undici agent:
// dns-cache.mjs — custom lookup with TTL, IPv4-only, pinned resolver
import dns from 'node:dns';
import { setGlobalDispatcher, Agent } from 'undici';

dns.setServers(['1.1.1.1']); // affects dns.resolve*, not dns.lookup

const cache = new Map();
const TTL_MS = 60_000;

function lookup(hostname, options, callback) {
  const now = Date.now();
  const hit = cache.get(hostname);
  if (hit && now - hit.fetchedAt < TTL_MS) {
    return callback(null, hit.address, 4);
  }
  dns.resolve4(hostname, (err, addresses) => {
    if (err) return callback(err);
    const address = addresses[0];
    cache.set(hostname, { address, fetchedAt: now });
    callback(null, address, 4);
  });
}

setGlobalDispatcher(new Agent({ connect: { lookup } }));
Enter fullscreen mode Exit fullscreen mode

If you prefer not to import undici directly, the same lookup function can be passed to https.request via its lookup option.

  1. Force IPv4-first resolution. dns.setDefaultResultOrder('ipv4first') at startup prevented the process from trying unreachable IPv6 paths before falling back to IPv4. This was a minor win compared to the cache, but it removed an entire class of timeouts.
  2. Retry only DNS-class errors, with jitter. This is deliberately not the retry-everything loop that turns a rate limit into a storm:
async function fetchWithDnsRetry(url, options = {}, retries = 3) {
  for (let attempt = 1; attempt <= retries; attempt++) {
    try {
      return await fetch(url, options);
    } catch (err) {
      const dnsLike = ['ENOTFOUND', 'ETIMEDOUT', 'EAI_AGAIN', 'SERVFAIL']
        .includes(err?.cause?.code);
      if (!dnsLike || attempt === retries) throw err;
      const jitter = Math.floor(Math.random() * 400);
      await new Promise(r => setTimeout(r, 500 * attempt + jitter));
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The worker has been stable since. The provider's endpoint was never the problem, and my original bug report would have sent the maintainers on the same wild goose chase I just finished.

The reusable debugging checklist

  • When a request fails in code but works in curl, compare the resolution paths, not just the URLs.
  • Measure the layer you are about to blame. A fifty-line probe beats a confident hypothesis.
  • Check the hostname's TTL before assuming the failure is randomly intermittent.
  • Add DNS latency and failure counts to your health check. A health check that only opens a TCP socket will never catch a sick resolver.
  • If you add retries, classify the error first. Retrying everything is how you turn a five-second blip into a five-minute outage.

Limitations

This fix assumes the failure is on your outbound path. If your own domain's DNS is failing for your users, pinning a resolver on your server changes nothing. It also assumes your host allows outbound DNS to arbitrary resolvers; many free tiers block it, in which case you measure the provider resolver and cache aggressively instead of replacing it. And if your operation is not idempotent, retry only the connection setup, never the whole operation.

Who should not use this approach

If your service sits behind a managed load balancer or a CDN that terminates DNS for you, most of this is unnecessary. If the failures are rare and you are on a tight schedule, a simple retry wrapper is enough; you do not need a custom cache. But if you run long-lived workers on free servers and you have ever blamed an API for a problem that curl could not reproduce, run the probe first. It will save you the afternoon I lost.

Top comments (0)