Short answer: cap each mail-domain verification run, keep the last observed MX evidence, and show a pending reason that says whether the system is waiting for DNS, waiting for the next scheduled check, or waiting for the customer to correct a record. A finite run should stop consuming work without turning an unresolved domain into a failure.
For a media company moving newsroom mail to a new provider, the decision isn't whether a lookup returned once. It is whether the observed evidence matches the exact MX target set approved for the cutover, across enough scheduled observations to make the result useful to the people watching the migration. The first simple approach — poll until the records match — hides both the attempt budget and the reason for waiting. It also leaves a worker alive for a process whose timing it doesn't control.
The better experiment treats every lookup as an observation and every retry as a scheduled decision. Five attempts over a chosen window can be a sensible test fixture, but five is an example budget, not an Internet constant. Your DNS timing policy, rollback window, and support expectations should determine the real values.
How should bounded domain verification polling expose a customer-visible pending state reason?
Use a small state machine with pending, verified, and needs_action as outcomes. Keep transport or resolver details in internal telemetry; put the next useful fact in the customer-facing reason. A pending result should answer three questions: what was observed, what was expected, and when the system will check again.
Suppose press.example is expected to publish two provider MX targets, but the latest observation still contains the previous target. The visible state should remain pending while attempts remain, with wording such as: "We found the previous mail route. The next check is scheduled for 14:20 UTC." After the finite run ends, the verification job stops, but the domain record does not magically become invalid. The durable state can still be pending, now with a reason that says the automatic check window ended and a fresh check may be requested after the DNS change is confirmed.
Don't collapse that result into a generic error.
The state transition is based on evidence, not elapsed time alone:
-
verified: the normalized observed MX set equals the expected set. -
pending: the evidence does not match, attempts remain, and another observation is scheduled. -
needs_action: the evidence cannot converge without a customer change, such as a consistently different configured target after the run has exhausted its budget.
The exact final label is a product-language choice. I'm not sure that needs_action fits every support model; pending_customer may be clearer when a support team owns rechecks. What matters is that exhaustion and verification are separate dimensions. An attempt counter says the worker is done. It does not prove the DNS configuration is wrong.
Preserve DNS evidence instead of preserving a spinning worker
A useful verification record is a compact evaluation artifact. Store the expected MX targets, normalized observed targets, observation time, attempt count, maximum attempts, next-check time when one exists, and a stable reason code. The customer sees a sentence derived from that record; operators get the structured fields needed to reproduce the decision. This is the same move that makes a notebook evaluation survive production: retain inputs and outcomes, not the process that happened to calculate them.
Normalization needs an explicit contract. DNS MX data includes a preference value and an exchange name. Decide whether verification requires both fields to match or only the provider-approved exchange names, then test that rule. Lowercase names and remove a final root-label dot before comparing them if that is your chosen canonical form. Do not silently accept suffix matches: mail.provider.example.attacker.example is not mail.provider.example.
For deliverability evidence, MX verification is necessary for the routing change in this scenario, but it is not a complete email-authentication review. DMARC is published as a DNS TXT record below the _dmarc label and expresses policy for message validation and disposition. That is a separate check with a separate evidence type. Mixing it into an MX boolean makes support explanations worse and produces an evaluation that cannot say which control failed.
A long-lived polling request is tempting because the code looks short. The catch is that DNS change timing can outlive normal request budgets, process restarts erase in-memory counters, and a customer can't tell the difference between "still checking" and "forgotten." Schedule one lookup per job, persist the observation, then enqueue the next job only if the state machine allows it. A queue delay may change the observation time, so calculate the displayed next-check time from the scheduled job rather than from an optimistic UI timer.
That is the whole trick.
A focused Python example for finite verification retries
The example below isolates the decision function from DNS I/O. That makes it cheap to evaluate boundary cases in a notebook and keeps the production adapter replaceable. A Node.js service can use the same input and output contract; the important part is the persisted state transition, not the timer library.
from dataclasses import dataclass
from datetime import datetime, timedelta
from enum import Enum
class Status(str, Enum):
PENDING = "pending"
VERIFIED = "verified"
NEEDS_ACTION = "needs_action"
@dataclass(frozen=True)
class VerificationResult:
status: Status
reason_code: str
message: str
attempt: int
next_check_at: datetime | None
def normalize_exchange(value: str) -> str:
return value.strip().lower().rstrip(".")
def evaluate_mx_observation(
*,
expected: set[str],
observed: set[str],
attempt: int,
max_attempts: int,
observed_at: datetime,
retry_delay: timedelta,
) -> VerificationResult:
expected_normalized = {normalize_exchange(x) for x in expected}
observed_normalized = {normalize_exchange(x) for x in observed}
if observed_normalized == expected_normalized:
return VerificationResult(
status=Status.VERIFIED,
reason_code="mx_match",
message="The published mail routes match the expected routes.",
attempt=attempt,
next_check_at=None,
)
if attempt < max_attempts:
next_check = observed_at + retry_delay
return VerificationResult(
status=Status.PENDING,
reason_code="mx_not_yet_matched",
message=(
"The published mail routes do not match yet. "
f"The next check is scheduled for {next_check:%H:%M UTC}."
),
attempt=attempt,
next_check_at=next_check,
)
return VerificationResult(
status=Status.NEEDS_ACTION,
reason_code="check_window_complete",
message=(
"The automatic check window ended before the mail routes matched. "
"Confirm the DNS change, then request a fresh check."
),
attempt=attempt,
next_check_at=None,
)
The comparison is deliberately strict: an unexpected extra MX target prevents verification. That rule suits a controlled company-mail cutover where an old route is meaningful evidence. It is not universally correct. If a provider documents multiple valid target sets, model those sets explicitly instead of weakening equality into a partial match.
The scheduler should call this function with an attempt number stored beside the domain, persist the returned result, and enqueue only when status is pending. Use an idempotency key made from the domain, verification generation, and attempt number so duplicate queue delivery records the same logical observation. A new customer edit should start a new generation rather than reset the counter on the existing run; otherwise late jobs from the old run can overwrite newer evidence.
For the eval harness, I would make the table small and sharp: match on attempt 1, match on the final attempt, mismatch before the limit, mismatch at the limit, case and trailing-dot normalization, an unexpected extra target, and an empty observation. The empty case deserves its own fixture because the visible reason may need to distinguish "no MX answer observed" from "different MX target observed," even if both transitions remain pending. Keep those reason codes stable; support dashboards and client applications will depend on them more than on the prose.
What should you measure before adopting this retry pattern?
Measure the distribution of attempts to verification, time from the customer's recorded edit to the first match, the share of runs ending in needs_action, manual recheck frequency, and reason-code frequency. Those metrics reveal whether the budget is too short, but they do not prove that a longer budget is always better. Longer windows consume more scheduled work and delay a clear request for customer action; shorter windows increase rechecks and support contacts.
Prompt and notification cost belongs in the design too. If pending explanations are generated or rewritten by a model, send stable structured fields and cache the rendered message by reason code. There is little value in paying repeatedly to paraphrase the same evidence, and generated wording should never decide the state transition. The deterministic evaluator owns that decision.
This pattern is not suitable when the requirement is continuous DNS compliance after onboarding. Use a recurring reconciler for that job, because a finite verification generation intentionally stops. It is also a poor fit for an emergency cutover that demands human confirmation at an exact deadline; use an operator-run checklist with direct DNS evidence and an explicit rollback decision instead. For ordinary self-service onboarding, finite jobs are easier to reason about, cheaper to replay in an eval harness, and clearer to customers than an open-ended request.
Ship the state model before tuning the interval. Once observations, generations, and reasons are durable, the retry numbers can change without changing what verified means. Before copying the five-attempt example, run it against your own resolver path and migration timeline, then choose a budget that matches the evidence your support team is prepared to explain.
Further reading
DMARC record discovery and policy semantics are useful when the mail cutover expands into a separate authentication review. Keep that evaluation distinct from MX routing verification.
References
- RFC 7489, Domain-based Message Authentication, Reporting, and Conformance (DMARC): https://datatracker.ietf.org/doc/html/rfc7489
Top comments (0)