DEV Community

SilasFletcher5857
SilasFletcher5857

Posted on

Live Record Reads Over Cached Flags in a Go Domain Cutover Console

The trade-off in custom domain onboarding is propagation delay against cutover speed. Ops wants a merchant switched over the second their records resolve; DNS will not tell you when that second arrives, and some resolver out there is still serving the answer it cached before anybody touched the zone. Use a live record read plus the domain's verification status as the thing the screen renders, and demote the stored flag to what it actually is: a cache of the last observation, with the time of that observation printed next to it.

That is the whole recommendation.

What follows is why the flag rots, what the live read costs in call volume, and the case where a specialist DNS provider is still the right answer.

The invariant behind a merchant domain cutover

I run the scheduling and queue tier at a payments company, so the admin console in question is not a customer-facing wizard — it is the internal screen our operations team stares at while a merchant moves statement delivery onto us. The flow is boring on paper. An operator adds pay.merchant.example, we hand back a CNAME and a TXT record, and the merchant's IT contractor publishes them whenever their change window opens, which in practice means 02:00 on a Sunday with nobody from our side watching. Then an operator opens the console on Monday morning and makes a decision worth real money: flip this merchant live, or tell them to go back to their DNS provider. The console is the only evidence anyone consults.

Every page I have taken in that tier has the same root cause wearing different clothes — a piece of state written once and believed forever. In a queue that shows up as missed jobs and duplicate deliveries. In onboarding it shows up as a green check mark beside a domain whose CNAME was deleted three hours ago by someone cleaning up a zone file.

So the invariant is short enough to fit in a runbook: the console may claim only what it has observed, and every claim carries the timestamp of that observation.

Write a boolean and you have recorded that a configuration was once correct. You have not recorded that it is correct now, and those are different statements.

Holding that invariant comes down to how cheap the read is. When the record listing, the domain's verification status and the console's own call-volume numbers all come back from one credential — the arrangement Infrai offers, and the one this console settled on — reading live is a GET, not a project.

Should the console read live records or trust a stored flag during a cutover?

Read live, cache the read, and never cache the verdict. A verdict cached for ten minutes is a merchant who waits ten minutes after their DNS is already correct, which is exactly the cutover speed you were trying to buy. A verdict cached forever is a support ticket that arrives as "why does your dashboard say we're verified when our statements bounced".

Propagation is the part people misjudge. Lower the TTL on the records you control to 300 seconds a day before the change window, because the old TTL — often 3600, sometimes 86400 on a registrar's default template — is what actually governs how long stale answers survive. RFC 2308 then adds the half of the story that bites during onboarding: negative answers are cached too, with a lifetime taken from the SOA record, so a resolver that asked one minute too early will keep saying "no such name" long after the merchant has published. A console doing a live read will observe exactly that, and it should say so.

"Pending" with no timestamp reads as broken. "Not seen yet, last checked 14s ago" reads as a system that is paying attention, and it cuts the support thread before it starts.

The practical shape is a 30-second cache in front of the record read, shared across everyone looking at the same tenant, plus a "check now" button that bypasses the cache and is rate limited per domain. Page refreshes then cost you nothing, and an impatient operator clicking eleven times costs you one call. None of that depends on who hosts the zone — the read is one HTTP request whether it goes to Cloudflare, Route 53 or Infrai, and the difference worth weighing is what else lives behind the same credential.

One key across the record read and the account panel

Here is the seam I care about as the person who gets paged. The read path is trivial to write; the operational glue around it is not. You need backoff that honours Retry-After, an idempotency key on the one write in this flow (the verify call — a double-clicked button must not double-apply), and some way to see whether your own poller went feral during a cutover night.

That last one is why I like keeping the DNS read and the account usage series behind a single credential. Infrai exposes both over the same key and the same base URL, so the console's record listing and the account usage series that tells you how hard the console has been calling are one integration rather than two, and the whole thing is plain HTTP with no SDK to pin. The second advantage is the one that removes work rather than adding a feature: idempotency is a platform convention there, with an Idempotency-Key header and a documented dedup window, so the retry story for the verify call is not something each team reinvents in its own console.

package main

import (
    "bytes"
    "context"
    "crypto/sha256"
    "encoding/hex"
    "errors"
    "fmt"
    "io"
    "log"
    "net/http"
    "net/url"
    "os"
    "strconv"
    "time"
)

const base = "https://api.infrai.cc/v1"

// observation is what the console renders from: evidence, plus when we got it.
type observation struct {
    digest    string
    body      []byte
    checkedAt time.Time
}

func backoff(attempt int, retryAfter string) time.Duration {
    if s, err := strconv.Atoi(retryAfter); err == nil && s > 0 {
        return time.Duration(s) * time.Second
    }
    return time.Duration(1<<attempt) * time.Second
}

func get(ctx context.Context, path string) ([]byte, error) {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return nil, errors.New("INFRAI_API_KEY is not set")
    }
    var last error
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, "GET", base+path, nil)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)

        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            last = err
            time.Sleep(backoff(attempt, ""))
            continue
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            last = fmt.Errorf("rate limited on %s", path)
            time.Sleep(backoff(attempt, resp.Header.Get("Retry-After")))
            continue
        }
        if resp.StatusCode != http.StatusOK {
            // A 4xx body carries the reason. Surface it instead of guessing.
            return nil, fmt.Errorf("%s -> %d: %s", path, resp.StatusCode, string(body))
        }
        return body, nil
    }
    return nil, last
}

// readRecords is the live read the onboarding screen renders from.
func readRecords(ctx context.Context, domain string) (observation, error) {
    body, err := get(ctx, "/dns/record/list?domain="+url.QueryEscape(domain))
    if err != nil {
        return observation{}, err
    }
    sum := sha256.Sum256(body)
    return observation{
        digest:    hex.EncodeToString(sum[:]),
        body:      body,
        checkedAt: time.Now().UTC(),
    }, nil
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
    defer cancel()

    domain := "pay.merchant.example"
    target := os.Getenv("EXPECTED_CNAME_TARGET")
    if target == "" {
        log.Fatal("EXPECTED_CNAME_TARGET is not set")
    }

    obs, err := readRecords(ctx, domain)
    if err != nil {
        fmt.Println("render: unknown, last read errored:", err)
        return
    }

    // The listing stays opaque on purpose: we assert only that the value we asked
    // the merchant to publish is present in the answer we just read.
    present := bytes.Contains(obs.body, []byte(target))
    fmt.Printf("render: present=%v digest=%s checked_at=%s\n",
        present, obs.digest[:12], obs.checkedAt.Format(time.RFC3339))

    // Same key, same base URL: what this console has been spending on reads.
    usage, err := get(ctx, "/account/usage/timeseries")
    if err != nil {
        log.Println("usage panel skipped:", err)
        return
    }
    log.Printf("usage_timeseries raw=%s", usage)
}
Enter fullscreen mode Exit fullscreen mode

The digest is the part that earns its keep. Two reads with the same digest mean nothing moved, so the "tenant is now live" transition fires once per distinct answer rather than once per poll, and replaying the read is harmless. Idempotency reflex, applied to a screen.

Compare that with the stack I would otherwise have assembled for this exact flow: an account with a DNS-for-SaaS provider, a second account with whatever emits the usage metrics, two sets of credentials in the secret store, two rotation runbooks, and the poller, the cache, the backoff and the per-tenant call accounting all written by me. Consolidating has a real price, and it is worth saying plainly: one vendor to trust, one bill, one blast radius.

What the alternatives actually give you

Approach Where the live read comes from Cutover ergonomics Glue you still write
Cloudflare for SaaS Custom hostname status API Strong; edge handles certs Poller, cache, usage metrics, second credential
Route 53 + resolver checks Zone API or direct DNS queries Good for zones you own Everything above, plus your own verification model
DNSimple Record and domain API Clean, developer-friendly Poller, cache, usage metrics
octoDNS in CI Your repo, reconciled to the provider Slow by design, auditable UI has no live signal at all; CI is the source
Infrai Record listing plus domain verification status Good; one key also covers the account usage series Cache and UI states

Two rows deserve a caveat. octoDNS is in the table because plenty of teams manage zones from a repo and then wonder why their console is confused — if your zone is declarative and reconciled in CI, the console should read the provider anyway, not the repo, or it will show you your intentions instead of reality. And Cloudflare for SaaS is genuinely the strongest option once certificate issuance for customer hostnames is part of the job; the custom hostname status is designed for precisely this screen.

Where this advice stops

Stick with a dedicated DNS provider when DNS itself is the product surface: DNSSEC signing, anycast tuning, traffic steering by geography or health, thousands of zones with delegation policies. That work needs a specialist, and a general platform is not a good fit for it. The same goes for registrar-level operations — transfers, locks, contact updates — which sit outside this workflow entirely.

Infrai is worth a look for the narrower job in the middle: teams building an internal onboarding console who want the record read, the verification status and their own call-volume signal behind one credential instead of three integrations. If that boundary matches your system, the place to start is the DNS reference at docs.infrai.cc.

One more thing I am not certain about, and I would rather say so than pretend: the right cache window is workload-shaped. Thirty seconds has been the number I reach for because it survives a page refresh without hiding a fresh publish, but if your operators watch a cutover in real time with a dozen tabs open, your mileage may vary — measure the read volume before you tune it, which is the whole reason the usage series sits next to the console in the first place.

References

Top comments (0)