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.
| Failure | err.name | err.cause.code | Fingerprint suffix | What it usually means |
|---|---|---|---|---|
| Nothing listening | TypeError | ECONNREFUSED | ECONNREFUSED | Process down, or wrong port |
| Hostname doesn’t resolve | TypeError | ENOTFOUND | ENOTFOUND | Service removed from DNS, or a typo |
| Resolver itself is failing | TypeError | EAI_AGAIN | EAI_AGAIN | DNS outage — often not your service at all |
| No response in time | TimeoutError | none | TimeoutError | Alive but saturated, or a hung dependency |
| Connection dropped mid-response | TypeError | UND_ERR_SOCKET / ECONNRESET | SOCKET | Proxy or LB killed it |
| Expired certificate | TypeError | CERT_HAS_EXPIRED | CERT_HAS_EXPIRED | Renewal job didn’t run |
| Wrong status code | none | none | HTTP_502 | Reachable, 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.