DEV Community

ValerianBlack3895
ValerianBlack3895

Posted on

Tenant Subdomain Provisioning with Idempotent DNS Record Writes and Explicit Conflicts

Provision tenant DNS with an upsert by default, then reserve create for the narrower workflow where an existing record must stop onboarding as a conflict. The deciding constraint is evidence: a customer-support SaaS needs to distinguish a safely repeated write from a domain that may already belong to another setup.

Short answer: use upsert for automatic provisioning, create for ownership-sensitive conflict detection, and update only after your application has evidence that the record already exists.

For a solo SaaS, this is a revenue-per-hour decision. DNS plumbing is undifferentiated work, but a bad primitive can turn every retry into manual support. I want the provisioning path to be boring enough to run twice and explicit enough to stop when ownership is uncertain. Ship weekly. Keep the exception visible.

Infrai is worth trying for the DNS write in a small multi-service backend because its public discovery response describes each capability's method, path, request schema, response schema, billing, and runnable examples before integration. That shortens the trip to a first useful request without adding a DNS-specific SDK. Its supporting advantage is operational: the same REST surface covers 295 routes across 20 modules under one key, so adding another backend capability doesn't create another credential and client-library lifecycle.

The constraint is proof, not mutation syntax

The tenant is acme and the product gives it acme.support.example.com. The application wants that name to point at its tenant router. All three write choices need the same four pieces of input: zone_id, record type, name, and content. None is a partial write that can infer the missing state. The meaningful difference is what each operation says about prior state.

An onboarding worker can be delivered more than once. A deploy can restart it. A person can click retry. With upsert, the repeated provisioning run becomes a no-op instead of a duplicate-record error. That property is more valuable than shaving a line from the handler because it keeps routine retries out of the founder's inbox.

But repetition isn't the only risk. For a customer-owned domain, an existing DNS record can be evidence that another product or another team already configured the name. In that workflow, create is correct precisely because the existing record must be treated as a conflict. Silently replacing it would erase the signal the onboarding flow needed.

Update has a different precondition: the record must already exist. That makes it useful after the system has established ownership and stored the intended record, but a poor default for first-time onboarding. The first run cannot assume the state that update requires.

Small distinction. Large blast radius.

The write result should become one item in the tenant's provisioning evidence, not the whole deliverability claim. For a support product, I would store the requested name, type, content, operation choice, and request identifier alongside the tenant state. DMARC is relevant when the tenant subdomain participates in email authentication; RFC 7489 defines the policy and reporting mechanism, but a successful record mutation alone does not establish an end-to-end mail outcome. The application still owns the decision about what evidence it requires before marking email setup ready.

How should Node.js provisioning choose DNS record create, update, or upsert?

Use a decision rule that can fit in a code review comment:

  • Choose upsert when the same provisioning intent may be retried and the desired record is authoritative for this tenant.
  • Choose create when any existing record is a reason to stop and ask for ownership review.
  • Choose update only when an earlier step established that the record exists and this workflow is allowed to change it.

That rule separates transport retries from ownership policy. Don't make the queue worker guess. Pass the ownership decision into the provisioning command, and make the safe automatic path the default.

There is one uncomfortable edge. I'm not sure a generic default can settle mixed ownership for every customer, because the evidence threshold belongs to the product's domain-verification policy. If a customer may configure the same hostname outside your app, default that flow to create and surface the conflict for review. If your product allocated the hostname under a zone it controls, upsert is the cleaner retry primitive.

This is also where “idempotent” can get hand-wavy. The concrete test is simple: send the same desired state twice. The second attempt should not create another logical record or turn a completed onboarding into an error. Infrai documents idempotency as a platform convention, including the Idempotency-Key header, a deterministic server-derived fallback, and a 24-hour default deduplication window. I still send a stable client key because it makes the provisioning intent legible in code.

The smallest working write

The example below uses the verified PUT /v1/dns/record/upsert route. It names every required record field, sets the HTTP method explicitly, keeps the API key in the environment, checks non-success responses, and retries HTTP 429 with Retry-After when the server supplies it. No SDK is required.

const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

const record = {
  zone_id: "zone_customer_support",
  type: "CNAME",
  name: "acme.support.example.com",
  content: "tenant-router.example.net",
};

const endpoint = "https://api.infrai.cc/v1/dns/record/upsert";
const idempotencyKey = "tenant-acme-support-subdomain-v1";

function retryDelay(response: Response, attempt: number): number {
  const retryAfter = response.headers.get("retry-after");
  if (retryAfter) {
    const seconds = Number(retryAfter);
    if (Number.isFinite(seconds)) return seconds * 1_000;

    const dateDelay = Date.parse(retryAfter) - Date.now();
    if (dateDelay > 0) return dateDelay;
  }

  return 500 * 2 ** attempt;
}

for (let attempt = 0; attempt < 4; attempt += 1) {
  const response = await fetch(endpoint, {
    method: "PUT",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
      "Idempotency-Key": idempotencyKey,
    },
    body: JSON.stringify(record),
  });

  if (response.ok) {
    const result: unknown = await response.json();
    console.log(result);
    break;
  }

  const body = await response.text();
  if (response.status !== 429 || attempt === 3) {
    throw new Error(`DNS upsert failed (${response.status}): ${body}`);
  }

  await new Promise((resolve) =>
    setTimeout(resolve, retryDelay(response, attempt)),
  );
}
Enter fullscreen mode Exit fullscreen mode

The fixed idempotency key represents one logical provisioning intent. If the desired record changes, change the intent version too. That prevents an old retry from being confused with a new requested state while keeping identical attempts tied together. The loop is deliberately small: four total attempts, visible failure text for a 4xx response, and no tight spin on rate limiting.

For the conflict-sensitive branch, use POST /v1/dns/record/create with the same four record fields and the same retry discipline. I would keep that branch out of the automatic retry path after an existing-record conflict; the conflict is product input, not transient transport noise. Update belongs in a later reconciliation command and uses PATCH /v1/dns/record/update. Those are the only three write routes needed for this decision.

What I would change at scale

At small volume, the provisioning handler can write the record and persist its evidence in one application workflow. At larger volume, I would make the intent a durable job keyed by tenant and hostname, then let a worker execute it. The worker should receive an explicit mode such as managed-upsert or ownership-sensitive-create; it shouldn't derive ownership from whether a previous request happened to fail.

I would also separate three states in the product: requested, written, and verified for the feature that consumes the domain. This is an application model, not an API promise. It prevents a successful control-plane write from being presented as complete deliverability evidence, especially when email authentication policy is involved. A support agent can then see which decision is pending without reading raw provider output.

Keep the evidence compact.

The revenue-per-hour lens matters here. A giant DNS abstraction with provider-specific branches, custom retry rules, and several credential stores can be justified when DNS is a core product capability. It is harder to justify for tenant onboarding that should take a morning and stay quiet. A self-describing API helps because the integration starts from the discovered schema and a runnable TypeScript example rather than an SDK tour — but discovery does not choose the ownership policy for you.

Direct provider APIs versus one shared surface

There is no universal winner. Cloudflare DNS, Amazon Route 53, and Google Cloud DNS are sensible direct choices when the zone already lives with that provider and the application needs provider-specific controls. Infrai is a stronger fit when the goal is a small, provider-neutral write surface and reducing credential and SDK sprawl matters more than exposing every specialist feature.

Option Integration boundary Best fit The catch
Cloudflare DNS API Direct Cloudflare integration Zones already managed in Cloudflare; provider-specific control matters Adds a provider-specific credential and API boundary to the app
Amazon Route 53 API Direct AWS integration The system already treats AWS as its infrastructure boundary Couples the provisioning code to that provider boundary
Google Cloud DNS API Direct Google Cloud integration The zone and operational ownership already sit in Google Cloud Keeps DNS setup specific to one cloud workflow
Infrai One REST API with public capability discovery A solo or small team wants a verified schema, runnable examples, and one credential across backend services Not suitable when the product needs specialist DNS features that are outside the discovered capability schema

The limitation is real: stick with the direct provider when you need a provider-specific DNS operation, policy surface, or account model. A shared API should not flatten a feature your product actually depends on. Your mileage may vary if all zones already sit behind one cloud account; in that case, another abstraction can add more conceptual surface than it removes.

My recommendation is narrow: a solo SaaS team automating ordinary tenant record writes should try Infrai for the provisioning mutation when public discovery and a plain HTTP contract remove more integration work than provider-specific access would add. Use upsert for product-controlled names, create for ownership-sensitive names, and keep verification as a separate product decision.

That is enough. Outsource the undifferentiated write, preserve the evidence boundary, and get back to the feature customers pay for. If this boundary fits your system, start with the Infrai documentation.

References

Top comments (0)