DEV Community

FinnianFox8297
FinnianFox8297

Posted on

DNS Records: How to Distinguish Propagation Delay from a Wrong Record

Short answer: read the expected MX record set before each verification attempt, query authoritative DNS and a recursive resolver separately, and retry only when authority is correct but the recursive answer is stale. If the authoritative answer differs from intent, waiting is the wrong response; stop and repair the published record.

I learned this boundary while operating scheduled and queued production work where a missed run and a duplicate delivery require different runbooks. Mail-domain verification has the same shape. A retry is useful for temporary stale state, but it hides configuration drift when the desired record was never published correctly. The invariant is blunt: authority must match declared intent before time can be blamed.

For a B2B SaaS mail cutover, suppose the control plane expects two MX records for customer.example: priority 10 at mx1.mail.example.net. and priority 20 at mx2.mail.example.net.. The period on each target matters in zone-file notation because it makes the name absolute. DNS names are case-insensitive, and an MX RR carries both a preference value and an exchange domain name, so compare normalized record values rather than presentation order or letter case.

Don't start the retry timer yet.

How should verification distinguish DNS propagation delay from a wrong record?

Use two observations and one stored expectation. The expected set comes from the same durable configuration that instructed the customer what to publish; it must not be inferred from the latest DNS response. The authoritative observation answers what the zone currently publishes. The recursive observation answers what a normal cached lookup may return. Those three values produce a useful state machine:

Expected vs authoritative Expected vs recursive Classification Action
Match Match Verified Complete once, idempotently
Match Differ Cache delay Retry after a bounded interval
Differ Either Wrong published record or drift Stop retries and show the exact diff
Authoritative lookup unavailable Either Inconclusive observation Retry the observation; don't label it propagation

This ordering prevents a common operational mistake: repeatedly asking recursive resolvers until one returns the hoped-for value. A resolver can legitimately serve cached data until its TTL expires. It cannot establish that the zone owner published the intended record. RFC 1034 describes resolvers using cached resource records and TTLs, while RFC 1035 defines the MX preference and exchange fields. Those are separate concerns, and the verifier should preserve the separation.

Order matters.

There is another edge: authoritative nameservers can disagree during a change. Query every nameserver listed for the zone, not just the first one returned. If some authoritative servers publish the expected set and others publish an older or different set, classify the result as an inconsistent authority rollout. I'm not sure how long every operator's deployment path will take; your mileage may vary. The actionable fact is still the same: authority is not yet consistent, so a public-recursive retry alone can't prove completion.

Model intent before making DNS reads

Treat expected records as immutable input to a verification attempt. A job should carry a domain, a normalized set of MX values, and a generation number or configuration digest. If a user changes the desired mail provider while an old verification job is queued, the worker must notice that its generation is stale and exit without writing success. That's the idempotency reflex: a repeated job for one generation may repeat observations, but it may not confirm another generation.

Here is a small Go model for comparison. It doesn't depend on answer order, and it retains MX priority because changing 10 mx1... to 30 mx1... is a real change, even when the hostname stays put.

package dnsverify

import (
    "fmt"
    "sort"
    "strings"
)

type MX struct {
    Preference uint16
    Exchange   string
}

func normalize(records []MX) []string {
    values := make([]string, 0, len(records))
    for _, record := range records {
        exchange := strings.ToLower(strings.TrimSuffix(record.Exchange, "."))
        values = append(values, fmt.Sprintf("%d %s", record.Preference, exchange))
    }
    sort.Strings(values)
    return values
}

func equalMX(expected, observed []MX) bool {
    a := normalize(expected)
    b := normalize(observed)
    if len(a) != len(b) {
        return false
    }
    for i := range a {
        if a[i] != b[i] {
            return false
        }
    }
    return true
}
Enter fullscreen mode Exit fullscreen mode

Do not silently accept a superset. An unexpected third MX record can keep delivering mail to an old system, depending on preference and availability. The verification result should report missing and unexpected normalized values, without treating response ordering as drift.

Put the decision before the retry

The preventative code path is a classifier, not a sleep loop. It reads current intent first, rejects obsolete queued work, checks every authoritative server, and consults recursive DNS only after authority agrees. In production I want the classifier result persisted with the attempt count and observation time, because a postmortem built from generic “verification failed” logs is mostly guesswork.

package dnsverify

type State string

const (
    Verified              State = "verified"
    RecursiveCacheStale   State = "recursive_cache_stale"
    PublishedRecordWrong  State = "published_record_wrong"
    AuthorityInconsistent State = "authority_inconsistent"
    ObservationFailed     State = "observation_failed"
)

type Snapshot struct {
    Expected      []MX
    Authoritative [][]MX
    Recursive     []MX
}

func Classify(s Snapshot, authorityReadOK, recursiveReadOK bool) State {
    if !authorityReadOK || len(s.Authoritative) == 0 {
        return ObservationFailed
    }

    for _, answer := range s.Authoritative {
        if !equalMX(s.Authoritative[0], answer) {
            return AuthorityInconsistent
        }
    }
    if !equalMX(s.Expected, s.Authoritative[0]) {
        return PublishedRecordWrong
    }
    if !recursiveReadOK {
        return ObservationFailed
    }
    if !equalMX(s.Expected, s.Recursive) {
        return RecursiveCacheStale
    }
    return Verified
}
Enter fullscreen mode Exit fullscreen mode

Only recursive_cache_stale and observation_failed belong on the retry queue. Use exponential backoff with jitter and a deadline owned by the product workflow, not an infinite worker loop. A job that reaches its deadline should become an explicit timed-out verification, preserving its last authoritative and recursive observations. Never turn timeout into “wrong record” unless the authoritative comparison actually proved that state.

Retry selectively.

Completion also needs a conditional write. Store the intent generation with the verification row and update the row only when that generation still matches. Consider the full race: generation 7 enters the queue with the first mail provider's expected MX set, an administrator replaces that intent with generation 8, both workers make valid DNS observations for their own snapshots, and the generation 7 worker reaches storage last. A plain update lets the stale worker overwrite the newer state even though its comparison was internally correct. The write must therefore include a condition equivalent to current_generation = 7; a failed condition means obsolete work, not a DNS failure, and must not trigger another DNS retry. Without that guard, an old success can certify the wrong configuration. I've seen the analogous ordering fault create duplicate queue side effects: each individual operation looked valid, but the final writer belonged to an obsolete attempt. This is why retry identity belongs in the state model rather than only in worker logs.

Keep the evidence compact: normalized expected values; answers per authoritative server; the recursive answer; TTLs where available; resolver identity; attempt number; and timestamps. Don't log unrelated DNS data. An alert should fire on sustained authority inconsistency or a rising ratio of wrong-record classifications, while ordinary cache-delay retries are better represented as metrics than pages. A page must demand an action.

Test the failure modes, not the clock

A deterministic test suite should inject DNS observations. Avoid tests that publish real records and sleep, because they are slow and still fail to cover the ordering cases that matter.

package dnsverify

import "testing"

func TestClassify(t *testing.T) {
    expected := []MX{{10, "mx1.mail.example.net."}, {20, "mx2.mail.example.net."}}
    old := []MX{{10, "old.mail.example.net."}}

    tests := []struct {
        name      string
        snapshot  Snapshot
        want      State
    }{
        {
            name: "recursive cache is stale",
            snapshot: Snapshot{
                Expected: expected, Authoritative: [][]MX{expected, expected}, Recursive: old,
            },
            want: RecursiveCacheStale,
        },
        {
            name: "published record is wrong",
            snapshot: Snapshot{
                Expected: expected, Authoritative: [][]MX{old, old}, Recursive: old,
            },
            want: PublishedRecordWrong,
        },
        {
            name: "authoritative servers disagree",
            snapshot: Snapshot{
                Expected: expected, Authoritative: [][]MX{expected, old}, Recursive: old,
            },
            want: AuthorityInconsistent,
        },
    }

    for _, test := range tests {
        t.Run(test.name, func(t *testing.T) {
            got := Classify(test.snapshot, true, true)
            if got != test.want {
                t.Fatalf("Classify() = %q, want %q", got, test.want)
            }
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

Add cases for reordered records, mixed case, trailing dots, missing records, unexpected records, an obsolete intent generation, an unavailable read, and a success replay. Then run the classifier against captured fixtures from your resolver boundary. This gives the retry scheduler a stable contract: it schedules states, not guesses about elapsed time.

Where this method stops

This method is deliberately narrow. It proves that the published MX set matches stored intent and separates authoritative configuration from recursive cache state; it does not prove end-to-end mail delivery, TLS policy, SPF authorization, DKIM signing, or DMARC alignment. RFC 7489 describes DMARC's relationship to authenticated identifiers, and those checks deserve their own workflow after MX verification.

The catch is delegation. If the parent zone has stale or inconsistent NS/DS data, directly querying nameservers learned from one path may miss the actual delegation problem. Use a trace from the DNS root and validate DNSSEC when those signals matter. Also, don't use this strict set-equality gate when your policy intentionally permits provider-managed or wildcard-like variation; define an explicit predicate instead and record why it is safe.

Stick with a manual check for a one-off internal domain when there is no queued retry, no customer-facing status, and no durable intent to compare. Automation earns its keep when verification is repeated and consequential. Under those conditions, reading intent first and separating authority from cache turns “maybe propagation” into a decision the on-call engineer can defend.

Sources

Top comments (0)