Tracking failed health checks in Node: timeouts, ECONNREFUSED and DNS

Classify fetch failures from a Node 22 health prober into stable fingerprints, ship them to Infrai's capture route, then search and close the groups.

A health prober that reports every failure as “health check failed” is worse than no prober at all — a week later you have 4,000 identical events and no idea whether the payments service was refusing connections, resolving to nothing, or just slow. The fix is classification before transmission: read the error’s cause.code, turn it into a fingerprint like healthcheck:payments-api:ECONNREFUSED, and let the store aggregate. Infrai’s POST /v1/errors/capture takes that fingerprint verbatim and hands back the group it landed in.

Node 22 makes the classification easy, because fetch failures carry a machine-readable cause. Everything below runs on stock Node — no SDK, no agent, one HTTPS call per failure.

What a failing probe actually throws

Global fetch wraps transport problems in a TypeError: fetch failed and puts the real reason on err.cause. Timeouts are the exception: AbortSignal.timeout() rejects with a TimeoutError and no cause at all. Those two shapes cover almost everything a health prober sees.

Failureerr.nameerr.cause.codeFingerprint suffixWhat it usually means
Nothing listeningTypeErrorECONNREFUSEDECONNREFUSEDProcess down, or wrong port
Hostname doesn’t resolveTypeErrorENOTFOUNDENOTFOUNDService removed from DNS, or a typo
Resolver itself is failingTypeErrorEAI_AGAINEAI_AGAINDNS outage — often not your service at all
No response in timeTimeoutErrornoneTimeoutErrorAlive but saturated, or a hung dependency
Connection dropped mid-responseTypeErrorUND_ERR_SOCKET / ECONNRESETSOCKETProxy or LB killed it
Expired certificateTypeErrorCERT_HAS_EXPIREDCERT_HAS_EXPIREDRenewal job didn’t run
Wrong status codenonenoneHTTP_502Reachable, unhealthy

Those codes are Node’s own system error names, documented alongside undici’s error list, so this table doesn’t depend on anyone’s SDK staying current.

Worth flagging one modelling decision before the code: the target service belongs in the fingerprint, the timestamp and the IP address don’t. healthcheck:payments-api:ECONNREFUSED groups every refused connection to that one service into a single row, no matter which pod or which minute.

The prober

const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
const RELEASE = process.env.APP_RELEASE ?? "dev";
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

const TARGETS = [
  { name: "payments-api", url: "https://payments.internal/healthz" },
  { name: "search-api", url: "https://search.internal/healthz" },
];

function classify(err, res) {
  if (res && !res.ok) return { kind: `HTTP_${res.status}`, detail: `unexpected status ${res.status}` };
  if (err?.name === "TimeoutError") return { kind: "TimeoutError", detail: "no response within the probe budget" };
  const code = err?.cause?.code;
  if (code === "ECONNRESET" || code === "UND_ERR_SOCKET") return { kind: "SOCKET", detail: err.cause.message };
  if (code) return { kind: code, detail: err.cause.message ?? err.message };
  return { kind: "UNKNOWN", detail: err?.message ?? "no error and no response" };
}

async function capture({ name, url, kind, detail }) {
  const res = await fetch(`${API}/v1/errors/capture`, {
    method: "POST",
    headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify({
      message: `health check failed: GET ${url} — ${detail}`,
      exception: "HealthCheckFailed",
      fingerprint: `healthcheck:${name}:${kind}`,
      environment: process.env.NODE_ENV ?? "production",
      release: RELEASE,
    }),
  });
  if (!res.ok) {
    console.error("capture rejected", res.status, await res.text());
    return null;
  }
  const { data } = await res.json();
  if (data.is_new_group) console.warn(`NEW failure mode for ${name}: ${kind} -> ${data.dashboard_url}`);
  return data;
}

export async function probeAll(timeoutMs = 3000) {
  const results = [];
  for (const target of TARGETS) {
    let res = null;
    let err = null;
    try {
      res = await fetch(target.url, { signal: AbortSignal.timeout(timeoutMs), headers: { accept: "application/json" } });
    } catch (e) {
      err = e;
    }
    if (res?.ok) {
      results.push({ target: target.name, healthy: true });
      continue;
    }
    const { kind, detail } = classify(err, res);
    const captured = await capture({ ...target, kind, detail });
    results.push({ target: target.name, healthy: false, kind, group: captured?.error_group_id ?? null });
  }
  return results;
}

console.log(JSON.stringify(await probeAll(), null, 2));

Two details in there matter more than they look. The probe budget is 3,000 ms and it’s an argument, not a constant, because a probe timeout shorter than your service’s p99 turns into a self-inflicted incident. And a non-2xx response is classified from res.status rather than thrown — fetch doesn’t reject on HTTP 502, which is the single most common reason a hand-rolled prober silently reports everything as healthy.

What the capture returns

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/errors/capture" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "health check failed: GET https://payments.internal/healthz — connect ECONNREFUSED 10.0.4.19:8443",
    "exception": "HealthCheckFailed",
    "fingerprint": "healthcheck:payments-api:ECONNREFUSED",
    "environment": "production",
    "release": "2026.07.6"
  }'
{
  "ok": true,
  "data": {
    "event_id": "evt_err_AAiAQ1HDVlsX4XXwZH09ZsVL",
    "fingerprint": "4813402dbf47045a97350f305067143a997df0365faf99337adbec06f433b04e",
    "error_group_id": "errgrp_oj2NliMIICqqpnxVqLUVzEM5",
    "is_new_group": true,
    "dashboard_url": "https://infrai.cc/projects/proj_local/errors/evt_err_AAiAQ1HDVlsX4XXwZH09ZsVL"
  }
}

is_new_group is the alerting hook. A prober that has been reporting TimeoutError all week and suddenly reports ECONNREFUSED opens a new group, and that transition — slow to dead — is the one you want waking someone.

Reading the outage back

Every occurrence in a group is retrievable, so you can reconstruct the shape of an outage from the event timeline:

curl -sS "https://api.infrai.cc/v1/errors/events/errgrp_oj2NliMIICqqpnxVqLUVzEM5?limit=20" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

One event, in full, with its stored envelope:

curl -sS "https://api.infrai.cc/v1/errors/get/evt_err_AAiAQ1HDVlsX4XXwZH09ZsVL" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

For a broader sweep, GET /v1/errors/list filters on facets — environment, level and release all narrow the result set, and pagination runs off the opaque next_cursor:

curl -sS "https://api.infrai.cc/v1/errors/list?environment=production&level=error&limit=20" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

The free-text route is separate. GET /v1/errors/search?q=ECONNREFUSED matches on the message text, which is where the target URL and the remote address ended up — handy at 3am when you remember an IP but not a service name. In our testing the search route ignores environment and level if you add them, so filter with the list route and search with the search route rather than mixing the two.

Closing the loop when the service comes back

There’s no auto-resolve on recovery, so your prober should close its own groups:

const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

export async function resolveGroup(groupId) {
  const res = await fetch(`https://api.infrai.cc/v1/errors/resolve/${groupId}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify({ error_group_id: groupId }),
  });
  if (!res.ok) throw new Error(`resolve failed: ${res.status}`);
  const { data } = await res.json();
  return data.is_resolved;
}

If the failure returns, the next capture with that fingerprint unresolves the group automatically and bumps its count. So “resolved” means “healthy as of the last probe”, which is the only honest definition a prober can offer.

Cost, and the limits of doing it this way

Capture is billable at $0.00005 per event; list, search, get, events, groups and group_detail are free and rate-limited. A 60-second probe cycle across 5 services costs nothing while everything is green, because healthy probes send no events at all — you only pay when something’s broken. Check real spend rather than modelling it:

curl -sS "https://api.infrai.cc/v1/account/usage" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" | jq '.data.breakdown[] | select(.key | startswith("errors"))'

That figure was verified 2026-07-26, rates here tend to drift downward, and discount runs happen — so treat it as a reading, not a constant.

The drawback of a self-hosted prober is that it runs inside the network it’s testing. If your whole region goes dark, nobody probes and nobody captures. Datadog Synthetics and Honeybadger’s uptime checks run from outside your infrastructure and page you when the site is unreachable from the internet, which is a genuinely different guarantee — if external verification is what you need, buy one of those and keep this loop for the internal dependencies they can’t see. Infrai’s errors namespace also has no alert routing or on-call rotation; Sentry’s alerting docs are the reference for what a full product does there, and pairing capture with an email or SMS call on the same key is the cheap approximation.

References

Browse more errors developer guides