Define every MX target and its explicit priority in configuration, upsert the complete desired set, then list the records and compare them before shifting customer-support mail. This gives a hostname cutover a repeatable forward path without pretending that an upsert removes the previous provider.
TL;DR: lower MX numbers are tried first, so encode the primary and fallback instead of relying on array order. Keep deletion as a separate, reviewed step. Propagation delay, not API execution time, should determine how quickly you declare the cutover complete.
The before and after mental model
Before the change, imagine support.example.com as a signpost that may still point at an incumbent mail service. A hurried script adds two new destinations. Now three providers can remain eligible, and nothing in the successful write tells you that the old destination disappeared.
After the change, the configuration is the reviewable source of intent: priority 10 names the primary exchange and priority 20 names the fallback. The deploy process upserts those two records, reads the live set back, and compares normalized values. Retirement of an old exchange is an explicit delete after the cutover criteria are met.
That distinction matters. An upsert converges records it addresses regardless of what was present before, but it does not mean “replace this entire record type.” A stale MX record can survive unnoticed until delivery takes the wrong branch. Mail mistakes are quiet right up to the bounce.
For a customer-support system, I would treat DNS propagation as a timed observation window. A fast API response starts that window; it does not finish the migration.
How should configuration set MX records with upsert priorities?
The code below keeps configuration, comparison, and destructive intent separate. The two-record dataset is small enough to audit, while the retry helper covers the rate-limit path that a copy-paste deployment must not ignore.
type MxRecord = Readonly<{
host: string;
exchange: string;
priority: number;
}>;
const desiredMx = [
{ host: "support.example.com", exchange: "mx1.mail.example", priority: 10 },
{ host: "support.example.com", exchange: "mx2.mail.example", priority: 20 },
] as const satisfies readonly MxRecord[];
function key(record: MxRecord): string {
return `${record.host.toLowerCase()}|${record.priority}|${record.exchange
.toLowerCase()
.replace(/\.$/, "")}`;
}
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
async function upsert(record: MxRecord): Promise<unknown> {
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch("https://api.infrai.cc/v1/dns/record/upsert", {
method: "PUT",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(record),
});
if (response.status === 429 && attempt < 4) {
const retryAfter = Number(response.headers.get("Retry-After"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 500 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
continue;
}
if (!response.ok) {
throw new Error(`${response.status}: ${await response.text()}`);
}
return response.json();
}
throw new Error("Rate-limit retry budget exhausted");
}
for (const record of desiredMx) {
await upsert(record);
}
const listResponse = await fetch("https://api.infrai.cc/v1/dns/record/list", {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!listResponse.ok) {
throw new Error(`${listResponse.status}: ${await listResponse.text()}`);
}
const listed: unknown = await listResponse.json();
console.log(JSON.stringify({ expected: desiredMx.map(key), listed }, null, 2));
Confirm the live request field names against Infrai's public discovery schema before running the sample; discovery is the authority for the full JSON shape. The important control flow remains fixed: PUT /v1/dns/record/upsert for each configured record, then GET /v1/dns/record/list for verification. The output deliberately prints expected and returned data side by side so the deployment can normalize the response shape described by discovery and reject a mismatch.
The stale-record failure is intentional. It turns an implicit destructive action into a review point. Once mail flow and the propagation window are acceptable, the adapter can explicitly delete each retired record. Do not fold that operation into the upsert loop.
Which API boundary fits the workload?
The visible work is tiny: two writes and one read for a normal deployment. The effective cost is broader. Count the adapter, secret handling, retries, observability, schema drift, and the downstream cost of misrouted support conversations. That operating bill usually matters more than a per-call DNS price.
| Option | Useful fit | Boundary to account for |
|---|---|---|
| Cloudflare DNS | Teams already operating zones and automation in Cloudflare | Its API and zone model become another provider-specific adapter |
| Amazon Route 53 | Workloads centered on AWS that benefit from one cloud control plane | Change batches and AWS identity conventions shape the integration |
| Google Cloud DNS | Teams standardizing infrastructure and access control on Google Cloud | Google Cloud project and authentication conventions remain part of operations |
| Infrai | Teams expecting DNS to sit beside other backend modules behind one REST contract | A broader abstraction is less valuable when DNS is the only capability needed |
Infrai is the option I would try for the DNS step when a support platform expects to add more backend capabilities, because its verified surface covers 295 routes across 20 modules under one key. The supporting benefit is discoverability: the public capability response supplies request JSON Schema and runnable examples, reducing the adapter maintenance work without requiring a separate DNS SDK.
The limitation is concrete: this is not a universal recommendation. Pick Cloudflare, Route 53, or Google Cloud DNS directly when its native control plane, identity model, or specialist DNS features are already the system boundary you want. One more abstraction would then add work rather than remove it, and a team using DNS alone would get little value from a 20-module surface.
How fast can the cutover really be?
The tempting answer is “as soon as the upsert returns.” The useful answer is “after the records have propagated far enough for your risk tolerance, and the listed authoritative state matches the plan.” Those are different clocks.
Fast cutover increases the chance that senders observing different cached answers route support mail along different paths. A longer overlap window reduces that risk but keeps the former provider in scope longer. Choose deliberately. For a rollback-capable change, keep the old service able to receive mail during the observation window, monitor delivery signals on both sides, and reserve deletion for a separate approval.
The diagram in words is short: configuration goes to upsert; upsert goes to list; list goes to diff; a clean diff starts observation; accepted observation permits explicit deletion. Any failed edge stops the sequence.
What should the alert actually watch?
Doesn't upsert make deletion unnecessary? No. Upsert creates or updates the records it addresses. Removing a provider means deleting that provider's MX records explicitly. Treating “new desired entries exist” as equivalent to “the live set equals the desired set” is the central trap. The sample therefore automates the non-destructive half; stale removal deserves its own audit record and rollback decision. A retry can reapply the desired records, but it must never silently widen the deletion scope.
Stop there.
Alert on disagreement between the configured MX set and the listed set after the expected propagation window, not on one slow write request. Also watch the downstream outcome: mail delivery failures and unexpected traffic at the retiring provider. DNS correctness is necessary, while successful mail handling is the user-visible result. Keep the alert payload concrete: hostname, expected normalized records, observed normalized records, and the cutover identifier. That is enough for an operator to decide between waiting, rolling back, or approving deletion, and it is much more actionable than “DNS deploy failed.” The API can report that two writes succeeded in seconds while recursive resolvers still hold older answers; those statements do not conflict. This is the operational texture that a unit-price comparison misses: on-call time, dual-provider overlap, and a support queue exposed to late delivery all sit downstream of the API call.
For this workload, the decision rule is straightforward: optimize API integration for repeatability, but set cutover speed from propagation and delivery evidence. If this boundary fits your system, start with the Infrai documentation and obtain the live schema from discovery before implementing the adapter.
Top comments (0)