We manage all of our DNS zones with Ansible. The playbook derives the zone contents
from the web server’s vhost definitions, so every deployment renders the full desired
state and applies it to deSEC — nine zones, on every run.
This works well, but the read side of the API is where we keep running into limits.
What a run looks like
For each domain, a client that wants to compute a diff has to:
GET /domains/{name}/— does the zone exist?GET /domains/{name}/rrsets/— what is the current state?
That is two requests per zone, so 18 for our nine zones. A run that also wants to
guard against accidental deletions does a dry run first and then applies, which
doubles it to 36. Three --check runs inside a minute reach 54 requests and the
account is throttled:
HTTP 429
{"detail": "Request was throttled. Expected available in 13 seconds."}
The sliding window means the next run is throttled as well, because the aged-out
requests only free up gradually. During a deSEC deployment we additionally see
502 Bad Gateway from the nginx in front of the API, which a naive client counts as
a failure and aborts on.
Why IaC is different from a human
A person editing records in the web UI makes a handful of requests. Declarative
tooling polls the full state of every zone on every run to prove that nothing
changed, and the cost scales with zones × requests per zone × runs. That is
exactly the traffic pattern dns_api_cheap (10/s, 50/min) is tightest for.
We already handle it client-side: our Ansible module retries on 429/5xx with a
fixed backoff, and --check sends nothing. So this is not a bug report — it is a
question about whether the limits could accommodate automation better.
Ideas, roughly in order of how much they would help us
1. Treat zonefile export as the read path for full-zone reads.
GET /domains/{name}/zonefile/ falls under dns_api_expensive (10/s, 300/min,
1000/h) while listing RRsets falls under dns_api_cheap (10/s, 50/min). Reading the
whole zone is therefore six times less rate-limited than listing it, and it is one
request instead of two. Clients that need the complete state would happily parse a
zonefile; the endpoint already exists and produces exactly that.
2. Conditional requests.
An ETag/Last-Modified on the RRset listing, answered with 304 Not Modified,
would make no-op polling nearly free. published/touched on the domain object
already carry the information — if a client could compare a token and skip the
listing, an unchanged deployment would cost one cheap request per zone instead of a
full listing.
3. A bulk read.
GET /domains/?rrsets=true, or GET /rrsets/?domains=a.com,b.com, would collapse
n zones into one request. Even a cap of ten domains per call would remove most of
the pressure for accounts that manage many zones.
4. A plan/diff endpoint.
If the API accepted a proposed RRset list and answered with the computed diff (the
same atomic PATCH it already performs, but without committing), clients would not
need a separate read phase at all. This is probably the biggest structural win for
declarative tooling, but also the largest change.
5. Higher ceilings for a trusted/automation tier.
Concretely: a separate, higher bucket for token-authenticated requests, or an opt-in
“automation” flag on a token that lifts dns_api_cheap (e.g. to 300/min), keeping
the current limits as the default. Eligibility could be tied to something the
account already proves — DNSSEC-signed zones, a verified organisation, a paid
support tier, or simply a manual review. Rate limits protect against brute force and
runaway clients; a token that has been in use for months without a single 4xx
is not the threat model those limits were written for.
Is there anything like that available today, or a process to request it? And is
Retry-After guaranteed on 429 for all endpoints? We use a fixed backoff because
the value would need to be plumbed through our HTTP layer, but honouring the header
would be the better behaviour if it is stable.
For other people running into this
- Set the TTL of your records once and do not re-read them on every run; cache the
last applied state locally and only query when the rendered definition changes. - Run
--checksparingly. It costs the same reads as a real run. - Retry
429,502,503,504and connection errors, but never400/403—
those will not get better by waiting. - Do not parallelise across zones. The limits are per account, so concurrency makes
the window fill faster without improving throughput.