Commit Graph
4 Commits
Author SHA1 Message Date
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