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>
This commit is contained in:
John Winter Murphy
2026-08-24 11:04:03 -04:00
committed by GitHub
co-authored by jw
parent 77c9e12e2d
commit 04fcd20954
2 changed files with 117 additions and 1 deletions
+57
View File
@@ -226,6 +226,63 @@ class DynadotDNSTests(unittest.TestCase):
self.assertEqual("fixed-request-id", seen["X-Request-ID"])
self.assertEqual(expected, seen["X-Signature"])
def test_the_socket_receives_the_exact_header_name(self):
"""Assert on the wire, not on the Request object.
The previous attempt at this bug checked Request.header_items() and
passed, while urllib went on to title-case every name inside do_open, so
X-Request-ID still left as X-Request-Id and nothing changed. A data
structure that holds the right string is not evidence that the right
string was sent.
"""
import http.client as http_client
from ampg import dynadot_dns
captured = {}
class FakeConnection:
def __init__(self, host, port=None, timeout=None):
pass
def request(self, method, target, body=None, headers=None):
captured.update(headers or {})
def getresponse(self):
class Response:
status, reason, headers = 200, "OK", {}
def read(self):
return json.dumps(
{
"code": 200,
"data": {
"glue_info": {
"glue_type": "DNS",
"ttl": 1800,
"dns_main_list": [],
"dns_sub_list": [],
}
},
}
).encode()
return Response()
def close(self):
pass
original = http_client.HTTPSConnection
http_client.HTTPSConnection = FakeConnection
try:
provider = DynadotDNSProvider(credentials=self.credentials)
provider.get_hosts("example.test")
finally:
http_client.HTTPSConnection = original
self.assertIn("X-Request-ID", captured)
self.assertNotIn("X-Request-Id", captured)
self.assertNotIn("X-request-id", captured)
def test_mixed_ttl_and_broad_source_allowlist_fail_closed(self):
provider = DynadotDNSProvider(credentials=self.credentials)
with self.assertRaisesRegex(ValueError, "one observed"):