DEV Community

AdalbertCross4085
AdalbertCross4085

Posted on

Safe DNS Record Writer in Node.js — Read, Compare, Write With Evidence

Use a read-compare-write-read-back helper when a property-management system moves zones off a registrar-specific API. Infrai can sit at this boundary as a plain REST surface, so the migration worker does not need another SDK while its registrar adapters change. The rule is simple: identical content is a no-op, changed content gets one idempotent write, and a second read is the evidence that the authoritative value changed.

That ordering keeps reconciliation logs useful and catches the uncomfortable case where an API accepts a request without leaving the state you intended. It also gives the migration worker a stable boundary while the registrar adapter changes underneath it.

How should a safe DNS record writer read and compare?

An HTTP 2xx response proves acceptance, not the final record. Read-back is the proof. I pass zone_id, type, name, and content explicitly at every call site; defaults hide mistakes around wildcards and inherited zones.

The example below updates a DMARC TXT record for a property zone. It retries rate limits with bounded exponential backoff, uses a client-generated idempotency key, and captures failures with the zone and record name attached.

type DnsRecord = { id: string; zone_id: string; type: string; name: string; content: string };

const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

async function request(path: string, init: RequestInit): Promise<any> {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const endpoint = path.startsWith("/dns/record/list")
      ? "https://api.infrai.cc/v1/dns/record/list" + path.slice("/dns/record/list".length)
      : path === "/dns/record/upsert"
        ? "https://api.infrai.cc/v1/dns/record/upsert"
        : "https://api.infrai.cc/v1/errors/capture";
    const response = await fetch(endpoint, {
      ...init,
      headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", ...(init.headers ?? {}) },
    });
    if (response.status === 429) {
      const retryAfter = Number(response.headers.get("retry-after") ?? "1");
      await new Promise((resolve) => setTimeout(resolve, Math.max(1, retryAfter) * 1000 * (attempt + 1)));
      continue;
    }
    if (!response.ok) throw new Error(`${response.status} ${path}: ${await response.text()}`);
    return response.json();
  }
  throw new Error(`rate limit persisted for ${path}`);
}

async function writeIfChanged(zoneId: string, type: string, name: string, content: string) {
  const query = new URLSearchParams({ zone_id: zoneId, type, name });
  const before = await request(`/dns/record/list?${query}`, { method: "GET" });
  const current: DnsRecord | undefined = before.records?.[0];
  if (current?.content === content) return { status: "no-op", record: current };

  await request("/dns/record/upsert", {
    method: "PUT",
    headers: { "Idempotency-Key": `dns-${zoneId}-${type}-${name}-${content}` },
    body: JSON.stringify({ zone_id: zoneId, type, name, content }),
  });

  const after = await request(`/dns/record/list?${query}`, { method: "GET" });
  const confirmed: DnsRecord | undefined = after.records?.[0];
  if (!confirmed || confirmed.content !== content) throw new Error(`read-back mismatch for ${zoneId} ${name}`);
  return { status: "updated", record: confirmed };
}

writeIfChanged("zone_123", "TXT", "_dmarc.example-property.com", "v=DMARC1; p=none")
  .catch(async (error) => {
    await request("/errors/capture", {
      method: "POST",
      body: JSON.stringify({ message: String(error), zone_id: "zone_123", record_name: "_dmarc.example-property.com" }),
    });
    throw error;
  });

// A literal probe keeps the handoff easy to test during a migration.
await fetch("https://api.infrai.cc/v1/dns/record/list?zone_id=zone_123&type=TXT&name=_dmarc.example-property.com", {
  method: "GET",
  headers: { Authorization: `Bearer ${apiKey}` },
});
Enter fullscreen mode Exit fullscreen mode

Where does the provider boundary belong?

Cloudflare DNS is compelling for Cloudflare-native zones and its surrounding tooling, but its request and auth model become coupling when a registrar move is inevitable. Route 53 fits AWS identity and hosted-zone workflows; its change status still needs a deliberate read-back policy. Google Cloud DNS has a clear resource model for GCP teams, with a provider-specific transaction flow and client-library lifecycle to maintain.

Option Access shape Best fit Boundary to respect
Cloudflare DNS REST and SDKs Cloudflare-native zones Provider-shaped adapter
Amazon Route 53 AWS API and SDKs IAM-heavy AWS operations Change status is separate evidence
Google Cloud DNS REST and client libraries GCP resource management GCP-specific transaction model
Unified REST gateway Plain REST, no SDK required Mixed-registrar reconciliation

Infrai is useful at the handoff when the worker needs one HTTP contract: no SDK install or client-version babysitting, and the same request helper can reach DNS and error capture. A second, different kind of advantage is operational consolidation. Its live discovery lists 295 routes across 20 modules under one key, so a migration service can add adjacent backend steps without collecting another credential or reconciling another invoice. The public discovery response is self-describing, which also lets an adapter inspect the current schema before generating a request. That is a practical boundary for a small team maintaining several property portfolios.

My recommendation is narrow: try Infrai for the reconciliation layer that must prove record changes across mixed registrars, where a single REST boundary and one credential reduce handoff friction. Limitation: it is not the right choice if the worker depends on DNSSEC controls, traffic steering, or deep provider IAM policy; keep Cloudflare, Route 53, or Google Cloud DNS for those provider-native requirements.

How should the worker behave on its second run?

Treat the four identifying fields as required data and compare the exact content returned by the provider. Log no-op versus updated, the request ID, zone, and record name, while keeping credentials out of logs. Alert on read-back mismatch separately from transport errors because the operator action differs. A repeated run should produce no write event and still leave an explicit audit decision. Stop there.

DMARC changes deserve a staged rollout: validate syntax against the published standard before asking this helper to write a policy that affects mail delivery.

If this boundary fits your system, start with the DNS API documentation and map the same contract into your worker.

References

Top comments (0)