Short answer: use scheduled retries as the safety net, and let the customer trigger an immediate recheck; treat DNS propagation as an observable state, not a single yes/no event.
When an e-commerce merchant connects shop.example, the product has to prove control before it serves checkout traffic or sends mail. The awkward part is that the customer can publish a correct record while different resolvers still hold an older answer. A verification worker that runs once will call a healthy setup broken; a worker that retries forever will hide a typo and burn capacity.
This is an architecture decision record for that boundary. I care about durable state and explicit failure limits, so the design below records what the system knows, when it checked, and why it will check again.
The decision and its invariants
Choose a hybrid flow. A customer click schedules an immediate attempt, then a bounded four-step retry plan runs at increasing intervals: 30 seconds, 2 minutes, 10 minutes, and 30 minutes. A successful observation stops the plan. Exhaustion produces an actionable verification_pending state, not a false success.
The invariants matter more than the interval values:
- The expected token is generated per domain and stored with an expiry.
- Every attempt records resolver, record type, observed values, and timestamp.
- A positive result requires the expected value, not merely that a record exists.
- A retry is idempotent; duplicate queue messages cannot extend the deadline.
- Customer-triggered checks are rate-limited and share the same verifier as scheduled work.
The customer-owned zone remains authoritative for the record. A platform-owned zone can make delegation predictable, but it changes the onboarding contract and moves DNS operations into your support boundary.
| Choice | Strength | Failure boundary | Suitable when |
|---|---|---|---|
| Customer-owned zone | No nameserver migration | TTL and negative caching delay visibility | Merchants already operate DNS |
| Platform-owned zone | Predictable records and automation | You operate delegation, DNSSEC, and outages | You sell managed domain infrastructure |
| Hybrid (this decision) | Fast feedback plus bounded work | More state transitions to observe | Most self-serve storefront onboarding |
How should polling, scheduled retries, and customer rechecks handle propagation?
Polling is a measurement policy, not proof of propagation. Recursive resolvers cache positive answers for the record's TTL and negative answers according to the SOA's negative TTL; RFC 2308 describes why a missing record can remain missing after the customer fixes it. Therefore, querying one resolver repeatedly gives a narrow view.
For each attempt, query at least two independent recursive resolvers and, when the result is surprising, query the authoritative nameserver directly. Do not require every public resolver to agree: that turns normal cache convergence into a support ticket. Require a quorum such as two matching positive observations, then keep checking in the background until the onboarding deadline.
The trigger path should enqueue work rather than run DNS queries inside the HTTP request. A click returns 202 Accepted with the current state; a worker owns retries and deduplication. This keeps a slow resolver from holding a browser connection and gives operators one queue to inspect.
Here is the critical path. It uses dnspython because Python's standard library does not provide a portable TXT resolver. Install version 2.x in the worker image and pin it in your lockfile.
from datetime import datetime, timezone
import dns.resolver
RESOLVERS = ["1.1.1.1", "8.8.8.8"]
def read_txt(name: str, server: str) -> set[str]:
resolver = dns.resolver.Resolver(configure=False)
resolver.nameservers = [server]
resolver.lifetime = 3.0
answers = resolver.resolve(name, "TXT")
return {b"".join(part if isinstance(part, bytes) else part.encode() for part in r.strings).decode() for r in answers}
def verify_token(record_name: str, expected: str) -> dict:
observations = []
for server in RESOLVERS:
checked_at = datetime.now(timezone.utc).isoformat()
try:
values = read_txt(record_name, server)
observations.append({"server": server, "values": sorted(values), "at": checked_at})
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.exception.Timeout):
observations.append({"server": server, "values": [], "at": checked_at})
positive = sum(expected in item["values"] for item in observations)
return {
"status": "verified" if positive >= 2 else "verification_pending",
"positive_resolvers": positive,
"observations": observations,
}
The code intentionally returns evidence. An operator can distinguish NXDOMAIN from a resolver timeout, and the customer can see the exact record name to inspect. In production, encrypt or minimize that log if tokens are considered sensitive.
Failure modes that change the retry policy
The common mistake is treating all negatives as the same. A typo in the host label is deterministic; a cache miss is temporal. They deserve different next actions.
If authoritative data lacks the token, stop the fast retry loop and show the required name and value. If authoritative data is correct but recursive answers disagree, continue the bounded schedule. If both resolvers time out, classify the attempt as resolver_unavailable and alert separately; silently counting it as a customer error corrupts your onboarding metrics.
CNAME flattening, provider-specific dashboard fields, and DNSSEC validation add other edges. Route 53 exposes record-set APIs and hosted-zone delegation; Cloudflare DNS combines authoritative hosting with proxy controls; NS1 emphasizes programmable traffic policies. Those are different operational surfaces, not interchangeable proofs of domain ownership. Your verifier should consume DNS answers, not vendor dashboard state.
I once assumed a customer click meant “check now and finish.” It meant “check now, then leave a durable job.” Without that second clause, a deploy that publishes the record ten seconds later gets reported as a failure and the merchant retries the whole onboarding flow.
Three words: measure, classify, expire.
Rejected option and the valid exception
I rejected an always-on one-minute poller. It creates noisy traffic after a typo, makes queue pressure proportional to abandoned signups, and provides no clear handoff to support. A long fixed interval has the opposite problem: correct records appear stuck.
An always-on poller is valid for a small, internal fleet where the platform owns every zone and can guarantee a uniform TTL. It is not suitable when customers control nameservers, use DNS providers with different negative-TTL behavior, or expect an immediate “try again” button.
The hybrid schedule also needs a hard deadline, such as 43 minutes from the first attempt, and a manual reactivation path after that deadline. Keep the token valid long enough to cover the deadline plus the largest expected cache window, then rotate it when the customer asks to restart. Never extend a job merely because a duplicate click arrived. The limitation is operational overhead: you must retain evidence, expire jobs, and explain resolver disagreement to support staff.
It expires.
Operating the decision
Instrument four counters: attempts by outcome, seconds from authoritative success to recursive quorum, expired jobs, and customer-triggered checks per domain. Sample the resolver names and response codes, but avoid logging full customer data in broad metrics.
Test with a DNS test zone that lets you vary TTL, NXDOMAIN, delayed publication, and DNSSEC responses. The acceptance test is not “the API returned 200”; it is “the state converges, evidence is retained, and expiration is deterministic.”
Revisit the customer-owned versus platform-owned choice when support volume, DNSSEC requirements, or delegated-zone staffing changes. The right answer is a boundary decision, not a permanent preference.
Top comments (0)