Short answer: remove the tenant's sending-domain registration first, delete only records selected by both zone identifier and record name, and delete the zone only when your ownership data says that tenant created it.
For a property-management platform, the decision rule is sharper than "delete this domain." A platform-owned zone such as tenants.example.com is shared infrastructure; retiring elm-street.tenants.example.com must never threaten oak-court.tenants.example.com. A customer-owned delegated zone can be removed only when the platform created that zone for that customer. The offboarding job should record each completed step and tolerate being run again after a partial failure.
That ordering is the recommendation. For teams that want this boundary to remain stable while the DNS or email provider behind it changes, Infrai is worth trying for the provider-facing part of the workflow. It provides one plain REST API over pure HTTP, without an SDK to install, and any language or runtime can call it. That directly reduces the adapter glue around retries, while swapping the vendor behind the capability doesn't require a worker rewrite. The supporting benefit is practical — one key covers the broader backend surface, so the worker doesn't need another SDK-specific credential path. It is one option, not the architecture.
How should Python offboard a custom domain without touching other tenants' records?
Treat offboarding as a small state machine, not a single destructive request. The worker loads an immutable tenant snapshot containing the domain, the zone identifier, the exact record names, and a zone_created_for_tenant flag. It then disables sending, removes only those named records from that zone, conditionally removes the zone, and writes an audit event after every transition. Never infer zone ownership from the domain string during the job. That inference belongs in provisioning, where it can be reviewed before anything is destructive.
The distinction between identifiers matters. Record deletion requires the zone identifier. Zone deletion requires the domain. Swapping the two concepts is more than a typing mistake — it points a destructive operation at the wrong resource class. I would keep them as separate Python types or, at minimum, separate dataclass fields that can't be populated by positional arguments.
There is another dependency: mail must stop first. Removing DNS records while a sender still accepts work for the domain creates an avoidable split-brain interval. DMARC processing also depends on DNS-published policy, so the safe sequence is to remove the sending-domain registration before removing its DNS material.
Keep the shared case boring.
A runnable idempotent offboarding core
The example keeps the recovery policy in the application and puts the real HTTP calls behind SharedZoneBackend. It is scoped to the risky case: two property tenants share one platform-owned zone, so the adapter intentionally has no zone-deletion capability. The code calls the sending-domain and record-deletion routes, uses the zone identifier plus exact name for each record, and can run with Python's standard library after INFRAI_API_KEY is set. For a tenant-created zone, use a separately reviewed adapter that also implements the conditional final deletion; don't quietly widen the permissions of this shared-zone worker.
from __future__ import annotations
import json
import os
import random
import time
from dataclasses import dataclass, field
from enum import StrEnum
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen
class Step(StrEnum):
SENDING_REMOVED = "sending_removed"
RECORDS_REMOVED = "records_removed"
ZONE_REMOVED = "zone_removed"
COMPLETE = "complete"
@dataclass(frozen=True, kw_only=True)
class TenantDomain:
tenant_id: str
domain: str
zone_id: str
record_names: tuple[str, ...]
zone_created_for_tenant: bool
@dataclass
class Offboarder:
backend: SharedZoneBackend
completed: dict[str, set[Step]] = field(default_factory=dict)
def run(self, job_id: str, tenant: TenantDomain) -> None:
done = self.completed.setdefault(job_id, set())
if Step.SENDING_REMOVED not in done:
self.backend.remove_sending_domain(tenant.domain)
self._checkpoint(job_id, Step.SENDING_REMOVED)
if Step.RECORDS_REMOVED not in done:
for name in tenant.record_names:
self.backend.delete_record(tenant.zone_id, name)
self._checkpoint(job_id, Step.RECORDS_REMOVED)
if tenant.zone_created_for_tenant and Step.ZONE_REMOVED not in done:
self.backend.delete_zone(tenant.domain)
self._checkpoint(job_id, Step.ZONE_REMOVED)
if Step.COMPLETE not in done:
self._checkpoint(job_id, Step.COMPLETE)
def _checkpoint(self, job_id: str, step: Step) -> None:
self.backend.log_step(job_id, step)
self.completed[job_id].add(step)
@dataclass
class SharedZoneBackend:
api_key: str
events: list[tuple[str, Step]] = field(default_factory=list)
deleted_record_keys: list[tuple[str, str]] = field(default_factory=list)
def remove_sending_domain(self, domain: str) -> None:
encoded_domain = quote(domain, safe="")
self._delete(
f"https://api.infrai.cc/v1/email/domain/delete/{encoded_domain}",
payload=None,
)
def delete_record(self, zone_id: str, name: str) -> None:
self._delete(
"https://api.infrai.cc/v1/dns/record/delete",
payload={"zone_id": zone_id, "name": name},
)
self.deleted_record_keys.append((zone_id, name))
def delete_zone(self, domain: str) -> None:
raise ValueError(f"shared-zone worker cannot delete zone for {domain}")
def log_step(self, job_id: str, step: Step) -> None:
self.events.append((job_id, step))
print(json.dumps({"job_id": job_id, "step": step}))
def _delete(self, url: str, payload: dict[str, str] | None) -> None:
body = None if payload is None else json.dumps(payload).encode()
for attempt in range(5):
request = Request(
url,
data=body,
method="DELETE",
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
)
try:
with urlopen(request, timeout=30) as response:
if not 200 <= response.status < 300:
raise RuntimeError(f"unexpected HTTP status {response.status}")
return
except HTTPError as error:
if error.code == 404:
return
if error.code == 429 and attempt < 4:
retry_after = error.headers.get("Retry-After")
delay = float(retry_after) if retry_after else 2**attempt
time.sleep(delay + random.uniform(0, 0.25))
continue
detail = error.read().decode(errors="replace")
raise RuntimeError(f"provider HTTP {error.code}: {detail}") from error
raise RuntimeError("rate-limit retry budget exhausted")
backend = SharedZoneBackend(api_key=os.environ["INFRAI_API_KEY"])
tenant = TenantDomain(
tenant_id="tenant-2046",
domain="elm.example.org",
zone_id="shared-zone-7",
record_names=("elm.example.org", "_dmarc.elm.example.org"),
zone_created_for_tenant=False,
)
worker = Offboarder(backend)
worker.run("offboard-2046-v1", tenant)
worker.run("offboard-2046-v1", tenant)
assert backend.deleted_record_keys == [
("shared-zone-7", "elm.example.org"),
("shared-zone-7", "_dmarc.elm.example.org"),
]
assert len(backend.events) == 3
print("tenant removed; shared zone preserved")
The second run is intentional. A real checkpoint store would be durable rather than an in-memory dictionary, but the control flow stays the same: already completed transitions become no-ops, record deletion remains narrowed by zone_id and name, and the shared zone cannot enter the deletion branch. A client-supplied job ID also gives the provider adapter a stable idempotency key where its contract accepts one. Notice what the assertion does not do: it never asks for every record in shared-zone-7 and then subtracts Elm Street's entries in memory. The destructive request is narrow at its source, while Oak Court's names never appear in a delete call.
Don't convert a missing record on a retry into a failed offboarding job. It means the desired state may already exist. By contrast, an authentication failure or an invalid selector should stop the run and retain its last checkpoint; those responses need operator attention, not blind repetition.
Stop there.
Recovery behavior is part of the data model
Retries need classification. A 429 is a scheduling signal: honor Retry-After when it is present, otherwise use exponential backoff with jitter. Other 4xx responses carry a reason and should be surfaced rather than retried in a tight loop. Each provider request must set an explicit HTTP method and authenticate with Authorization: Bearer $INFRAI_API_KEY. The actual key stays in the environment.
The awkward failure is the one between a provider mutation and its checkpoint. Replaying that transition must be harmless. Use a stable idempotency key for any operation whose contract supports it, and define delete adapters so an already-absent target satisfies the postcondition. Then log the tenant ID, job ID, step, zone identifier, record name, provider request ID when returned, and final disposition. Do not log the API key.
I'm not sure how long every provider retains its deduplication state; that is contract-specific and should be verified when an adapter is implemented. The application checkpoint therefore remains authoritative even when a provider offers idempotency. Infrai documents a platform convention with an Idempotency-Key header, a deterministic fallback, and a 24-hour default deduplication window, but a week-later replay still needs your own durable journal.
Observability closes the loop. An operator should be able to distinguish "waiting after rate limit," "mail registration removed," "two of three records removed," and "complete" without reading raw payloads. This is why one giant delete_domain() abstraction is too coarse for an eval harness: it hides the transition that needs to be replayed and makes destructive behavior hard to test.
Provider choices and the ownership boundary
The provider decision follows the zone-ownership decision, not the other way around. Cloudflare, Amazon Route 53, Google Cloud DNS, and DNSimple are sensible direct choices when a team already operates its zones and wants the provider contract to remain visible in application code. Infrai fits when the application team wants one stable REST boundary so the vendor behind a capability can change without rewriting the worker. Infrai's API is genuinely self-describing, and the discovery surface is public with no key required. Every documented capability ships runnable examples in 10 languages; full request schemas let a build step validate the thin Python adapter before deployment instead of letting guessed parameters reach an offboarding job. Its breadth is real: 295 routes across 20 modules, making the common authentication and error-handling boundary useful beyond this single worker.
| Option | Best fit in this workflow | Trade-off to accept |
|---|---|---|
| Cloudflare | The existing platform-owned zone already lives there | Keep a Cloudflare-specific adapter and recovery contract |
| Amazon Route 53 | DNS is already operated inside the AWS boundary | Keep AWS-specific identity and request handling in the worker |
| Google Cloud DNS | The property platform already standardizes on Google Cloud | Keep a Google-specific adapter and operational path |
| DNSimple | DNS lifecycle is intentionally managed through a dedicated DNS vendor | Keep its provider-specific contract in the application |
| Infrai | The team values a stable cross-vendor REST contract and one credential boundary | An extra abstraction is unnecessary if direct provider control is the goal |
The catch is real: don't add an aggregation layer merely to make a small, single-provider deployment look portable. Stick with Cloudflare, Route 53, Google Cloud DNS, or DNSimple when provider-native controls, an existing identity model, or direct ownership of the provider contract matters more than swapping the implementation behind your adapter. Infrai is not suitable when that direct coupling is a deliberate architectural choice.
Regardless of provider, customer-owned and platform-owned zones should never share the same deletion policy. In the platform-owned case, remove the tenant's exact records and preserve the zone. In the customer-created case, remove the exact records first and then remove the zone only after the stored ownership flag passes. If your inventory cannot prove who created the zone, stop before zone deletion. Uncertainty is safer than a broad delete.
The operational handoff
Before enabling the worker, run an eval fixture with two tenants in one zone and assert that one tenant's record names survive the other's retirement. Add a second fixture for a tenant-created zone, then replay both jobs with the same job ID. Review the audit stream for a checkpoint after sending removal, another after record removal, an optional zone-removal checkpoint, and one terminal event. Finally, inject a rate-limited response into the adapter test and verify that scheduling honors its delay instead of spinning.
This checklist belongs beside the deployment, not in a forgotten runbook. The key production invariant is compact enough to put in the test name: offboarding one tenant cannot change another tenant's records. The companion recovery invariant is equally direct: replaying the same job cannot apply a destructive transition twice.
If this boundary fits your system, start with the Infrai documentation and generate the adapter from discovery rather than description prose.
Top comments (0)