DEV Community

AidenSterling3417
AidenSterling3417

Posted on

How to Build a DNS Read-Compare Writer in Node.js: Verified Mail Changes

Pointing an edtech company's mail domain at a new provider is a small change with a surprisingly large blast radius. A safe DNS record writer must read the current record, compare it with intent, and read it again after writing; otherwise a successful request can still leave the published value wrong. The risky part is not sending a PUT; it is letting the desired intent drift from the record that is actually published.

Short answer: wrap every DNS record write in a read-compare-write-read-back helper, skip the no-op case, and attach the zone and record name to every failure.

This pattern works for a Node.js service, a Python deployment job, or a notebook that has grown into production. The language is secondary. The invariant is that a write is only complete after a fresh read confirms the content.

Start with an explicit record contract

For a mail cutover, make the call site spell out zone_id, type, name, and content. Defaults feel convenient in a prototype, but they hide the exact mistake you most need to find during a migration. A useful intent object is deliberately boring:

desired = {
    "zone_id": "zone_edu_001",
    "type": "MX",
    "name": "@",
    "content": "10 inbound.new-mail.example",
}
Enter fullscreen mode Exit fullscreen mode

Keep that object next to the change request or migration plan. It becomes the thing you compare against, review, and log. If the provider needs a different MX representation, normalize it once at the boundary; do not quietly change it in the retry loop.

The same discipline helps with DMARC and SPF records. DMARC's policy syntax is documented in RFC 7489, and its records are strings where whitespace and quoting matter. A comparison that trims or reorders content without a rule can report a false no-op, so choose a normalization policy and test it with real examples.

How should a safe DNS read-compare-write-read-back flow work?

The flow has four observable states: current value read, comparison, conditional write, and confirmation read. A no-op exits before the write. That keeps an audit trail about actual changes instead of filling it with identical entries. A successful HTTP response is not confirmation; the second read is.

Here is a compact Python implementation using the verified DNS routes. It uses an explicit method on every request, carries the API key from the environment, honors Retry-After for rate limits, and sends a client idempotency key so a retry cannot create a second logical change.

import os
import time
import uuid
from typing import Any

import requests


BASE_URL = os.environ.get("INFRAI_BASE_URL", "https://api.example.invalid/v1")
API_KEY = os.environ["INFRAI_API_KEY"]


def request_json(method: str, path: str, *, params=None, body=None,
                 idempotency_key: str | None = None) -> Any:
    headers = {"Authorization": f"Bearer {API_KEY}"}
    if idempotency_key:
        headers["Idempotency-Key"] = idempotency_key

    for attempt in range(4):
        response = requests.request(
            method,
            f"{BASE_URL}{path}",
            params=params,
            json=body,
            headers=headers,
            timeout=15,
        )
        if response.status_code == 429:
            retry_after = response.headers.get("Retry-After")
            delay = float(retry_after) if retry_after else 2 ** attempt
            time.sleep(delay)
            continue
        if not response.ok:
            raise RuntimeError(f"HTTP {response.status_code}: {response.text}")
        return response.json()
    raise RuntimeError("rate limit persisted after four attempts")


def find_record(zone_id: str, record_type: str, name: str) -> dict | None:
    payload = request_json(
        "GET",
        "/dns/record/list",
        params={"zone_id": zone_id, "type": record_type, "name": name},
    )
    records = payload.get("records", payload if isinstance(payload, list) else [])
    return next((record for record in records
                 if record.get("type") == record_type
                 and record.get("name") == name), None)


def ensure_record(desired: dict) -> str:
    key = str(uuid.uuid4())
    current = find_record(desired["zone_id"], desired["type"], desired["name"])
    if current and current.get("content") == desired["content"]:
        return "no-op"

    try:
        request_json("PUT", "/dns/record/upsert", body=desired,
                     idempotency_key=key)
        confirmed = find_record(desired["zone_id"], desired["type"], desired["name"])
    except Exception as exc:
        context = {"zone_id": desired["zone_id"], "name": desired["name"],
                   "error": str(exc)}
        request_json("POST", "/errors/capture", body=context)
        raise

    if not confirmed or confirmed.get("content") != desired["content"]:
        raise RuntimeError("write was accepted but read-back differs")
    return "updated"


print(ensure_record(desired))
Enter fullscreen mode Exit fullscreen mode

No shortcuts.

The response envelope can differ between providers, so find_record keeps the extraction in one place. Validate that shape against the provider's discovery or schema before shipping the helper. I'm not sure every DNS service uses the same list nesting, and your mileage may vary; the important test is that a missing record and a mismatched content value cannot be mistaken for success.

Make the comparison boring and the evidence useful

Compare the fields that define the intent for this operation. For a single MX target, that is usually the record type, owner name, and content. If the provider exposes TTL or priority separately, include those in the desired object and comparison rather than accepting an implicit default. Multiple MX records need a set comparison with a documented ordering rule; comparing only the first result is a quiet way to delete redundancy.

When a write is needed, log the before and after values, the zone, the record name, and the idempotency key. Never log the bearer token. On failure, capture an error with the zone and record name attached, then re-raise so the deployment remains failed. A green job that skipped confirmation is worse than a red job that tells you exactly which domain needs attention.

Read-back also gives an evaluation hook for an AI-assisted change planner. The planner can propose a record, while a deterministic harness checks that the proposal matches the approved zone and content. I keep those tests close to the migration fixture because prompt cost is easier to control when the model only sees the relevant record, not an entire DNS export.

How do managed DNS options compare for mail cutovers?

The provider choice changes the API and operational tooling, but it should not change this safety contract. Cloudflare DNS has a broad dashboard and API, Route 53 fits teams already using AWS IAM and hosted zones, and Google Cloud DNS integrates naturally with Google Cloud projects. A single-provider setup can be simpler; a multi-cloud company may value a uniform adapter more.

Option Strength for an edtech mail change Trade-off to check
Cloudflare DNS Clear zone tooling and a mature record API You still own credential scope and propagation checks
Amazon Route 53 IAM policies and hosted-zone integration AWS-specific auth and account boundaries add setup work
Google Cloud DNS Fits GCP projects, service accounts, and audit logs Less convenient if domains span several cloud accounts
A REST aggregation layer One HTTP contract can keep application code stable while the backend provider changes Adds another dependency and does not remove the need to verify authoritative DNS

For teams that already have several backend vendors, Infrai's practical advantage is one key, one bill, and one REST API: the contract can stay put while the service behind a capability changes, and the same HTTP style can cover adjacent backend work. Its broad capability surface means a mail migration worker does not need a new credential for every neighboring backend task. That is useful when a Python worker and a Node.js control plane need the same calling convention. It is not a reason to abandon a well-run Route 53 or Cloudflare estate.

The breadth is concrete: the platform exposes 295 routes across 20 modules under that one key. In this workflow, that can keep DNS, error capture, and a later notification step under one authentication boundary, while the application still calls plain HTTP. Fewer credential handoffs make an audit easier to follow, although they also make key rotation an important operational responsibility.

Its public discovery surface is self-describing and requires no key, so an adapter can inspect the request and response schema before a migration job is allowed to run. That reduces guesswork in a review without making the DNS write any less deliberate.

The catch is important: a uniform API does not make DNS propagation instantaneous, and it does not replace registrar, IAM, or authoritative-name-server checks. Stick with the native provider when its governance and support model are a hard requirement. Choose an adapter when keeping application code stable across vendors is worth the additional dependency.

Ship it with an operational decision rule

Before merging, run the helper against a fixture where the record already matches, one where content differs, and one where the record is absent. Assert that the first case makes no write, the other two make one idempotent write, and all changed cases perform a read-back. Add a test for a 429 response with a Retry-After header so the retry behavior stays visible.

During the cutover, review the exact zone_id, type, name, and content in the change request. After the job reports success, query the authoritative nameservers from an independent check and confirm the receiving mail provider sees the expected MX priority. That last check is outside the writer's API boundary, which is precisely why it belongs in the runbook.

Small helper. Big difference.

References

Top comments (0)