7 Commits
Author SHA1 Message Date
84e4c62fbd Retry a dropped Dynadot connection (#7)
A full-zone write died with SSL UNEXPECTED_EOF_WHILE_READING. Not our request:
three plain requests to their API from one machine gave 000, 200, 000 within a
few seconds. Their endpoint is simply unreliable, and the operator was being
made the retry loop.

Only a connection that produced no response is retried. An HTTP status is an
answer, however unwelcome, and repeating a request the server already judged
turns one refusal into several.

Retrying a POST is safe here specifically because set_hosts replaces the whole
zone with add_dns_to_current_setting false, so the same body applied twice
leaves the same zone and a reply lost on the way back costs a duplicate write
and nothing else. The comment says plainly that this does not transfer to an
endpoint that appends, and a test pins both halves of that reasoning.

Co-authored-by: jw <jw@jwmacbookm5.local>
2026-08-25 08:50:08 -04:00
b041a78d29 Write Dynadot records under the names Dynadot expects (#6)
The write path sent value1 and value2. Dynadot uses record_value1 and
record_value2 in both directions and answered with "if record_type is entered,
record_value1 must be entered", which reads like a missing value rather than a
misnamed one.

I corrected the reader for exactly this last time and left the writer alone,
having concluded the two spellings differed. They do not. The unit test agreed
with the code rather than with Dynadot, so it passed while every write was
refused.

That is now twice, in opposite directions, with a green suite each time. So this
adds a round trip: read a zone, write it straight back, and assert nothing
changed. It feeds the writer exactly what the reader produced, so a spelling the
two do not share shows up as a value that vanished, and it fails against the
code as it was.

Worth the care because set_hosts replaces the whole zone. A record the reader
drops or the writer misnames is a record deleted from a live domain, and the
first zone we are writing is a shop.

Co-authored-by: jw <jw@jwmacbookm5.local>
2026-08-25 08:06:20 -04:00
eb86a9df23 Read a Dynadot zone the way Dynadot actually reports one (#5)
A live zone was unreadable for two reasons, and the second was hidden behind
the first.

The TTL arrives as a string. "300" failed isinstance(ttl, int), while the write
path already coerced the same value with int(record.ttl): reading was stricter
than writing, so we refused zones we were perfectly capable of writing. A bool
is now excluded explicitly, because True is an int in Python and would have been
read as a TTL of 1 and then rejected for being out of range rather than for
being a bool.

Records arrive as record_value1 and are written back as value1. The reader knew
only the write spelling, the one it never receives, so every record on a real
zone came back incomplete. Both are accepted, response spelling first.

Either alone makes every Dynadot zone unreadable, which is why neither had been
noticed: no Dynadot zone had ever been read successfully.

This matters more than a parse error usually would. set_hosts replaces the whole
zone with what the caller passes, so a value the reader drops is a record
deleted from a live domain. The test is shaped from an actual response rather
than from the documentation, and asserts that all six records survive with their
values intact, including an MX preference and a record type as unusual as
STEALTH.

Co-authored-by: jw <jw@jwmacbookm5.local>
2026-08-24 11:30:17 -04:00
04fcd20954 Send Dynadot's headers through http.client so the names survive (#4)
The previous fix did not work. Assigning Request.headers preserves the name in
the object, and urllib then title-cases every header inside do_open, immediately
before writing them to the socket. X-Request-ID still went out as X-Request-Id,
which Dynadot rejects, so nothing changed.

I verified that fix by inspecting Request.header_items() and it passed. A data
structure holding the right string is not evidence that the right string was
sent, and that is the whole of my mistake.

No spelling survives .title() as X-Request-ID, so urllib cannot be made to send
it. The request goes out through http.client, which writes header names
verbatim. The urllib.request.Request stays as the unit handed to an injected
opener so the existing tests keep inspecting what would actually be
transmitted, and a 4xx is re-raised as urllib.error.HTTPError so the caller's
error handling, which reads the body to surface Dynadot's own explanation,
is untouched.

The new test captures the headers at the socket rather than on the Request, and
fails if the opener is put back to urllib.request.urlopen. That is the assertion
the previous attempt should have made.

Co-authored-by: jw <jw@jwmacbookm5.local>
2026-08-24 11:04:03 -04:00
77c9e12e2d Send X-Request-ID with the case Dynadot requires (#3)
Every Dynadot request this provider has ever made was rejected with "the
X-Signature provided is not valid". The signature was correct. The header name
was not.

urllib capitalises header names when they go through add_header, which the
Request constructor does for everything passed to it, so X-Request-ID left as
X-request-id. Header names are case-insensitive under RFC 9110 and Dynadot's
gateway is not: it matches X-Request-ID exactly, did not find the rewritten
form, and computed the signature with an empty request id while we computed ours
with the UUID. A correct signature over a different string, reported as a bad
signature.

That message sends you to the algorithm and the credentials, which is where I
spent the evening. The header we were plainly sending is the last thing anyone
would suspect.

Assigning the mapping after construction bypasses add_header and preserves the
name. Confirmed against the live API: exact X-Request-ID is accepted, and
X-Request-Id one letter different is rejected.

Two tests, both of which fail against the previous code: the wire name keeps its
case, and the id in the signature is the id in the header. The second is the
invariant that actually broke, and it can break again without the first.

Co-authored-by: jw <jw@jwmacbookm5.local>
2026-08-24 10:41:47 -04:00
a4b8a8729b Surface Dynadot's refusal instead of only its status code (#2)
A 400 from Dynadot arrived as "HTTP 400" and nothing else. Dynadot explains the
refusal in the response body and we were discarding it, so the operator was left
with a number that does not distinguish "the domain is not in this account" from
"the signature is wrong" from "the caller address is not allowlisted". All three
are 400 and all three have different fixes.

The body is bounded to 400 characters and quoted as remote text. No credential
can appear in it: the key travels in an Authorization header and the signature
is computed rather than echoed.

Reading the body is best-effort. A stream that cannot be read must not cost the
status code as well, so the failure to read is swallowed and the code is still
raised, which is what the second test pins.

Co-authored-by: jw <jw@jwmacbookm5.local>
2026-08-24 09:01:51 -04:00
jwinterm 0c6bbace07 feat: add guarded Dynadot DNS reconciliation 2026-07-31 15:07:41 -04:00