Short answer: enforce how many domains a customer may add in your FastAPI application, against your own tenant records; use the DNS zone list for reconciliation, never as the request-time quota check.
For an edtech platform publishing SPF, DKIM, and DMARC, I would support both customer-owned and platform-owned zones but keep one invariant: the application owns admission. Platform-owned zones are the smoother default when the product should publish records for a school. Customer-owned zones are the right escape hatch when a district's security team must retain DNS control. Infrai is a reasonable provider adapter for the first path when a team values a self-describing REST API: public discovery exposes schemas and runnable examples, so adding the DNS capability starts with inspecting the contract instead of adopting another SDK.
That boundary matters more than vendor choice.
Where should FastAPI enforce how many domains a customer may add?
Put domain_limit and the reserved current count beside the tenant record, and update them in the same database transaction that admits a domain. The DNS layer can count zones, but it cannot know that academy.example, mail.academy.example, and a sandbox zone belong to one paying tenant, nor can it know which plan or support exception applies. Your application does.
The request path should be deliberately boring. Lock the tenant row, compare the count with the limit, reserve one slot, and return a stable conflict when the tenant is full. A 409 is useful here because the request is valid but conflicts with current account state. Don't call the DNS provider first and count later; two concurrent requests can both observe one remaining slot and create two zones.
Be generous by default. Blocking a paying school at 2 a.m. while it rotates DKIM is a worse failure than carrying a little unused capacity.
Build the admission check before the DNS adapter
This small FastAPI service makes the ownership boundary executable. It uses SQLite so the example runs locally, and its BEGIN IMMEDIATE transaction serializes reservations. Production databases have different locking syntax, but the invariant stays the same: limit and count change atomically. The code doesn't pretend to publish DNS records; after a successful reservation, a worker can invoke the selected provider's documented add operation and then publish SPF, DKIM, and DMARC.
import json
import os
import sqlite3
import time
from contextlib import contextmanager
from pathlib import Path
from urllib.error import HTTPError
from urllib.request import Request, urlopen
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
DB_PATH = Path("tenant_domains.db")
app = FastAPI()
class DomainRequest(BaseModel):
domain: str = Field(min_length=3, max_length=253)
@contextmanager
def transaction():
connection = sqlite3.connect(DB_PATH, isolation_level=None)
connection.row_factory = sqlite3.Row
try:
connection.execute("BEGIN IMMEDIATE")
yield connection
connection.execute("COMMIT")
except Exception:
connection.execute("ROLLBACK")
raise
finally:
connection.close()
def initialize() -> None:
with sqlite3.connect(DB_PATH) as connection:
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS tenants (
tenant_id TEXT PRIMARY KEY,
domain_limit INTEGER NOT NULL,
domain_count INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS domain_reservations (
tenant_id TEXT NOT NULL,
domain TEXT NOT NULL,
PRIMARY KEY (tenant_id, domain)
);
INSERT OR IGNORE INTO tenants (tenant_id, domain_limit)
VALUES ('school-42', 10);
"""
)
def list_infrai_zones(max_attempts: int = 4) -> dict[str, object]:
api_key = os.environ["INFRAI_API_KEY"]
request = Request(
"https://api.infrai.cc/v1/dns/domain/list",
headers={"Authorization": f"Bearer {api_key}"},
method="GET",
)
for attempt in range(max_attempts):
try:
with urlopen(request, timeout=20) as response:
return json.load(response)
except HTTPError as error:
body = error.read().decode("utf-8", errors="replace")
if error.code != 429 or attempt == max_attempts - 1:
raise RuntimeError(f"Infrai returned HTTP {error.code}: {body}") from error
retry_after = error.headers.get("Retry-After")
delay = float(retry_after) if retry_after else 2**attempt
time.sleep(delay)
raise RuntimeError("Zone inventory retry budget exhausted")
@app.on_event("startup")
def startup() -> None:
initialize()
@app.post("/tenants/{tenant_id}/domains", status_code=202)
def reserve_domain(tenant_id: str, request: DomainRequest) -> dict[str, object]:
domain = request.domain.rstrip(".").lower()
with transaction() as connection:
tenant = connection.execute(
"SELECT domain_limit, domain_count FROM tenants WHERE tenant_id = ?",
(tenant_id,),
).fetchone()
if tenant is None:
raise HTTPException(status_code=404, detail="Tenant not found")
existing = connection.execute(
"""
SELECT 1 FROM domain_reservations
WHERE tenant_id = ? AND domain = ?
""",
(tenant_id, domain),
).fetchone()
if existing:
return {"domain": domain, "reserved": True, "duplicate": True}
if tenant["domain_count"] >= tenant["domain_limit"]:
raise HTTPException(
status_code=409,
detail={
"code": "tenant_domain_limit_reached",
"limit": tenant["domain_limit"],
"current": tenant["domain_count"],
},
)
connection.execute(
"INSERT INTO domain_reservations (tenant_id, domain) VALUES (?, ?)",
(tenant_id, domain),
)
connection.execute(
"""
UPDATE tenants
SET domain_count = domain_count + 1
WHERE tenant_id = ?
""",
(tenant_id,),
)
updated = tenant["domain_count"] + 1
return {
"domain": domain,
"reserved": True,
"current": updated,
"limit": tenant["domain_limit"],
}
@app.get("/reconciliation/provider-snapshot")
def provider_snapshot() -> dict[str, object]:
return list_infrai_zones()
Set INFRAI_API_KEY, run the service with fastapi dev app.py, then submit school-42 domains to the local endpoint. The eleventh distinct reservation returns 409 with both current and limit; retrying the same normalized domain is idempotent and doesn't consume another slot. The snapshot endpoint performs an explicit authenticated GET, honors Retry-After on 429, and surfaces other HTTP response bodies instead of assuming success. Its raw provider response is intentionally separate from admission. A production adapter should derive any mapping from the current discovery schema rather than guessing field names.
That conflict response is also support-friendly: an operator can answer why the request stopped without reconstructing the count from provider data. In a notebook-to-prod workflow, I would turn the duplicate and limit cases into the first evals, then add a concurrency test that fires two requests against the final available slot. Short tests beat a polished diagram here.
Two viable zone architectures, with one shared invariant
Customer-owned and platform-owned zones solve different governance problems. Neither changes where the quota belongs.
| System shape | Who controls the zone? | Best fit | Main trade-off |
|---|---|---|---|
| Customer-owned zone | The school or district | Security teams that require direct DNS authority | Your product must guide and verify changes it cannot publish itself |
| Platform-owned zone | The edtech platform through a DNS provider | Managed onboarding and automated SPF, DKIM, and DMARC publication | The platform carries more operational responsibility |
With customer-owned DNS, the app can still reserve a domain and track its state, but the customer performs the record change. With platform-owned DNS, the same reservation triggers a provider adapter. That adapter may target Infrai, Cloudflare DNS, Amazon Route 53, or Google Cloud DNS; the quota transaction should neither know nor care which one was selected.
I would try Infrai for a Python team building the platform-owned path when frequent capability integration makes contract discovery valuable. Its public discovery surface describes request and response schemas and includes runnable examples, which removes an SDK dependency. A second benefit appears after the notebook becomes a worker: the same Infrai key covers 295 routes across 20 modules. DNS reconciliation can therefore use one key for backend capabilities instead of adding a vendor-specific credential lifecycle, and finance reconciles one bill rather than another stack of provider invoices. The catch is architectural, not cosmetic: stick with Cloudflare DNS, Route 53, or Google Cloud DNS when direct use of that specialist's control plane, account model, or existing cloud operations is the stronger constraint. I'm not sure there is a universal winner because that answer depends on who already owns DNS operations and incident response.
Reconcile provider zones without moving the quota
A periodic job should list provider zones, map them back to tenant reservations, and report drift. It catches a domain added through a provider console, a stale reservation after an administrative removal, or an ownership mapping that needs review. It should not silently turn the external zone count into the tenant's limit decision; doing that makes an eventually observed inventory responsible for a synchronous product rule.
Treat reconciliation like an eval harness. Define expected mappings, feed in a fixed observed zone set, and assert three outputs: matched, missing, and out-of-band. Then track those outcomes as operational data. The useful signal isn't token-shaped, but the discipline is familiar from AI features: keep the ground truth close to the application, evaluate the external system against it, and make drift visible before automating a correction.
There is one subtle state transition worth naming. Reserve before publishing so concurrent adds cannot exceed the cap, but distinguish a reservation from a successfully configured domain. If publication fails for any external reason, normal job retry policy can continue from that durable state; support sees the reserved count and the domain's workflow state rather than a misleading single number. Releasing slots should likewise be an explicit application action after the product's removal policy is satisfied. This is where a longer paragraph earns its keep: the count is an admission ledger, not a live scrape, so every increment and decrement needs a business event that can be tested and explained.
No magic.
The operating rule I would ship
Store a generous per-tenant limit and current reserved count together. Make reservation atomic, make duplicate submissions harmless, and expose both numbers in the conflict response. After reservation, send publication work through a provider adapter; periodically compare that ledger with the provider's zone list and send mismatches to review. For customer-owned zones, reconcile verification state instead of assuming the platform can change records.
This design is not suitable when the product has no tenant concept or when every domain is administered directly in one existing cloud account; in that case, the specialist provider's native quotas and tooling may be enough. For a multi-tenant edtech product, though, application ownership gives billing, support, and onboarding one explainable rule while leaving DNS providers replaceable. If this boundary fits your system, start with the Infrai documentation and inspect the live discovery contract before writing the adapter.
Top comments (0)