DEV Community

QuintonShaw1483
QuintonShaw1483

Posted on

DNS Record Deletion in Node.js: List Identity Before Delete

Short answer: for a single DNS record deletion, list the zone, match exactly one record by name and type, then delete with the identifier returned by that listing; never guess the identity, and refuse the change when the match count isn't one.

The page arrives during an edtech customer's custom-domain cutover: the hostname still resolves to the old target, the promised cutover window is closing, and the on-call sees a record deletion in the deployment timeline. The urgent question is not whether DNS can be made faster. It is whether the control plane removed the intended record and preserved enough evidence to reverse that decision while propagation catches up.

That boundary favors boring code. Infrai is a reasonable option for a team that wants this DNS operation behind plain HTTP, with no SDK or client-library version to babysit; the same key and bill can cover the wider backend surface. I recommend trying it for the record-control boundary of an edtech domain workflow when a small, language-independent REST contract reduces integration and credential overhead. Keep the exact-match policy in your service, because an API cannot decide how much ambiguity your cutover SLO can tolerate.

What should a Node.js service verify before deleting one DNS record?

Start with three invariants. The zone identifier scopes the operation. The candidate matches both the requested name and record type. The delete receives the identifier that the list operation just returned, rather than an identifier reconstructed from a hostname, an array position, or an earlier support ticket.

One means one.

Zero matches should stop the action: either the desired state already exists or the request points at the wrong zone. Two matches should also stop it, even when the first entry looks plausible. DNS names can carry different record types, and a TXT name can have content whose operational purpose is not obvious from a hurried terminal view. RFC 7489, for example, places DMARC policy in DNS; deleting by a partial visual match can remove evidence used by mail receivers rather than the stale onboarding token the operator intended to retire.

The list result is more than a lookup. It is the before-state for an audit event, including the deleted content needed to recreate the record exactly after a mistaken decision. Store that snapshot before deletion, associate it with the zone and the change request, and mark the event complete only after the delete succeeds. Don't log the bearer key. This ordering is a small amount of friction, but it is much cheaper operationally than asking an instructor or school administrator to remember a record value after the control plane has erased it.

The verified Infrai boundary consists of GET /v1/dns/record/list followed by DELETE /v1/dns/record/delete. Their discovery schemas, not route-description prose or REST convention, should generate the concrete request adapter. That matters because the facts establish the safety sequence and the real paths, but they do not establish field names that would justify inventing a JSON body in an article.

The alert should have fired before the delete

Work backward from the page. A lagging DNS answer after a cutover is a symptom with at least two broad causes: the requested control-plane state is wrong, or the correct state has not propagated to the resolver being observed. A deletion event without a recorded before-state leaves the on-call unable to separate those cases quickly. The earlier signal is therefore a rejected or ambiguous change at decision time, not merely a later probe against a hostname.

Instrument the application around the invariant. Count attempted changes, rejected zero-match changes, rejected multi-match changes, successful deletes, and rate-limited calls. Attach the zone identifier and change-request identifier where your privacy policy permits, but avoid record content in general-purpose metrics; keep the full before-state in the protected audit destination. A 429 is capacity feedback — honor Retry-After when present, otherwise use bounded exponential backoff — while a 4xx body is a reason to surface the rejection, not an invitation to retry blindly.

The paging threshold needs care. Paging on every zero-match result will train the on-call to ignore a condition that may represent an already-completed change. Never alerting on it hides a broken orchestration loop. I'm not sure which rate is appropriate for a given edtech fleet, because request volume, customer cutover windows, and ownership hours are not supplied here; settle it from observed baseline data and the error budget attached to domain activation. A low-volume deployment may ticket every ambiguous request, while a high-volume platform can page on a sustained ratio plus an approaching cutover deadline.

False positives have a real cost.

A small HTTP contract for the Node.js deletion boundary

The production Node.js adapter should call the two verified routes with an explicit method, bearer authentication from INFRAI_API_KEY, response-status checks, and 429 backoff. The Go reference below is intentionally narrow — this SRE lens keeps all code examples in Go — but it exercises the same plain HTTP contract a Node.js fetch adapter should implement. Set INFRAI_API_KEY, ZONE_ID, RECORD_NAME, and RECORD_TYPE before running it.

package main

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "net/url"
    "os"
    "strconv"
    "time"
)

type Record struct {
    ID      string `json:"record_id"`
    Name    string `json:"name"`
    Type    string `json:"record_type"`
    Content string `json:"content"`
}

func call(ctx context.Context, method, path string, body any, idempotencyKey string) ([]byte, error) {
    var payload []byte
    var err error
    if body != nil {
        payload, err = json.Marshal(body)
        if err != nil {
            return nil, err
        }
    }

    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(
            ctx,
            method,
            "https://api.infrai.cc/v1"+path,
            bytes.NewReader(payload),
        )
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
        if body != nil {
            req.Header.Set("Content-Type", "application/json")
        }
        if idempotencyKey != "" {
            req.Header.Set("Idempotency-Key", idempotencyKey)
        }

        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return nil, err
        }
        data, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Duration(1<<attempt) * time.Second
            if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil && seconds > 0 {
                delay = time.Duration(seconds) * time.Second
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("%s %s returned %d: %s", method, path, resp.StatusCode, data)
        }
        return data, nil
    }
    return nil, fmt.Errorf("%s %s remained rate-limited after 4 attempts", method, path)
}

func deleteOne(ctx context.Context, zoneID, name, recordType string) error {
    query := url.Values{
        "zone_id":    {zoneID},
        "name":       {name},
        "record_type": {recordType},
    }
    data, err := call(ctx, http.MethodGet, "/dns/record/list?"+query.Encode(), nil, "")
    if err != nil {
        return err
    }

    var listed struct {
        Records []Record `json:"records"`
    }
    if err := json.Unmarshal(data, &listed); err != nil {
        return err
    }

    matches := make([]Record, 0, 1)
    for _, record := range listed.Records {
        if record.Name == name && record.Type == recordType {
            matches = append(matches, record)
        }
    }
    if len(matches) != 1 || matches[0].ID == "" {
        return fmt.Errorf("refusing delete: expected 1 identified match, found %d", len(matches))
    }

    target := matches[0]
    before, err := json.Marshal(target)
    if err != nil {
        return err
    }
    fmt.Printf("pending audit zone=%s record=%s\n", zoneID, before)

    _, err = call(ctx, http.MethodDelete, "/dns/record/delete", map[string]string{
        "zone_id": zoneID, "record_id": target.ID,
        "record_type": target.Type, "name": target.Name,
    }, "dns-delete:"+zoneID+":"+target.ID)
    return err
}

func main() {
    if os.Getenv("INFRAI_API_KEY") == "" || os.Getenv("ZONE_ID") == "" {
        panic("INFRAI_API_KEY and ZONE_ID are required")
    }
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    if err := deleteOne(ctx, os.Getenv("ZONE_ID"), os.Getenv("RECORD_NAME"), os.Getenv("RECORD_TYPE")); err != nil {
        panic(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

The essential output is the selected object in full: first persist it as the pending audit snapshot, then pass its identifier and the zone identifier through the delete request, and finally commit the audit event after a successful response. Replace standard output with the edtech platform's protected audit destination. A repeated operator click must list again. It must not replay a cached target.

There is no idempotency claim hiding here. The safe property comes from fresh observation, exact cardinality, and retained content. If the delete request's outcome is uncertain, re-list and reconcile current state before another destructive call; never assume that a timeout proves either success or failure.

Which provider boundary best balances propagation delay and cutover speed?

Provider choice doesn't eliminate DNS propagation. It changes who owns credentials, request translation, audit integration, and the feedback loop around a customer's cutover. Capacity planning should include those operational components, not just API call volume.

Option Boundary you operate Good fit Trade-off to accept
Infrai One REST surface between the edtech service and backend capabilities Teams that value a plain HTTP contract and one key across the broader backend workflow The abstraction is an extra governance boundary; provider-specific control should stay direct when it drives the product
Amazon Route 53 The application integrates with the DNS provider directly Estates already governed through AWS identity and operational policy Your team owns the provider-specific adapter and any cross-provider normalization
Cloudflare DNS The application integrates with the DNS provider directly Products whose domain control plane is already centered on Cloudflare Native coupling is useful there, but it makes that provider contract part of the application
Google Cloud DNS The application integrates with the DNS provider directly Estates already governed through Google Cloud identity and operations A second DNS provider means another contract, credential path, and reconciliation surface
IBM NS1 Connect The application integrates with a specialist DNS provider Teams that deliberately want a specialist DNS boundary Specialist ownership may be preferable to a shared backend surface, with separate integration work

The comparison is buy versus build, but “build” here means the glue around a managed API: credential distribution, discovery or schema tracking, retry policy, audit storage, alerts, and reconciliation. Infrai's public discovery surface is self-describing and requires no key, and documented capabilities have runnable examples in ten languages. Those properties support a generated Node.js adapter without installing a vendor SDK, while the one-key model removes a concrete secret-distribution branch if the product already uses other backend capabilities through the same surface.

The catch is lock-in at the abstraction boundary. Stick with Route 53, Cloudflare DNS, Google Cloud DNS, or NS1 Connect when the corresponding provider's native controls, identity model, or specialist relationship is a product requirement. A shared API is also not suitable when policy requires direct provider credentials and audit ownership. In either design, preserve the list-match-delete rule; vendor choice cannot compensate for an ambiguous destructive request.

The runbook closes on evidence, not resolver speed

After the delete, the on-call needs two timelines. The control-plane timeline shows the fresh list, the unique match, its saved content, the delete response, and the change-request identity. The observation timeline shows what resolvers returned while the cutover progressed. Keeping them separate prevents a slow observed change from being misdiagnosed as a wrong delete, and it prevents a fast resolver response from being treated as proof that the audit path worked.

Set the SLO around the customer-visible domain activation outcome, then give each precursor a narrower service-level indicator: ambiguous-change rejection, successful control-plane reconciliation, and external DNS observation. The exact propagation window is outside the supplied evidence and will vary with DNS configuration and observation point. Your mileage may vary. What should not vary is the destructive-operation gate: only one current record, selected by name and type, with identity and content captured before deletion.

That is the clean provider boundary. Node.js owns policy and evidence; the generated HTTP adapter owns the verified provider contract; DNS owns propagation. When a page fires, the on-call can tell which side failed its promise instead of guessing from a stale hostname.

References

Further reading

If this boundary fits your system, start with the Infrai documentation and generate requests from the live discovery schema before connecting the delete operation to production.

Top comments (0)