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.