diff --git a/Lib/test/test_urllib2.py b/Lib/test/test_urllib2.py index d2fd111f6d9de02..6c0b386a2c26255 100644 --- a/Lib/test/test_urllib2.py +++ b/Lib/test/test_urllib2.py @@ -1751,6 +1751,49 @@ def test_unsupported_auth_digest_handler(self): opener.add_handler(http_handler) self.assertRaises(ValueError, opener.open, "http://www.example.com") + def test_digest_auth_retry_count_reset(self): + self._test_digest_auth_retry_count_reset( + urllib.request.HTTPDigestAuthHandler(), 401, "WWW-Authenticate") + + def test_proxy_digest_auth_retry_count_reset(self): + self._test_digest_auth_retry_count_reset( + urllib.request.ProxyDigestAuthHandler(), 407, "Proxy-Authenticate") + + def _test_digest_auth_retry_count_reset(self, auth_handler, code, header): + # A failed authentication must not leave the retry count set, or the + # handler rejects valid credentials on every later request. + challenge = ('%s: Digest realm="ACME Networks", nonce="%%s", ' + 'qop="auth"\r\n\r\n' % header) + + class MockDigestHTTPHandler(urllib.request.BaseHandler): + # Answers with a fresh nonce every time, so the handler keeps + # retrying until it gives up. + def __init__(self): + self.count = 0 + + def http_open(self, req): + import email + self.count += 1 + msg = email.message_from_string(challenge % self.count) + return self.parent.error( + "http", req, MockFile(), code, "Unauthorized", msg) + + auth_handler.add_password("ACME Networks", "http://acme.example.com/", + "joe", "password") + opener = OpenerDirector() + opener.add_handler(auth_handler) + opener.add_handler(MockDigestHTTPHandler()) + with self.assertRaises(urllib.error.HTTPError) as cm: + opener.open("http://acme.example.com/protected") + cm.exception.close() + + # The same handler must still authenticate a later request. + opener = OpenerDirector() + opener.add_handler(auth_handler) + opener.add_handler(MockHTTPHandlerRedirect(code, challenge % "nonce")) + response = opener.open("http://acme.example.com/protected") + self.assertEqual(response.code, 200) + def test_unsupported_auth_basic_handler(self): # While using BasicAuthHandler opener = OpenerDirector() diff --git a/Lib/urllib/request.py b/Lib/urllib/request.py index 660301fef612588..c5ccd7b03e537e2 100644 --- a/Lib/urllib/request.py +++ b/Lib/urllib/request.py @@ -1210,10 +1210,11 @@ class HTTPDigestAuthHandler(BaseHandler, AbstractDigestAuthHandler): def http_error_401(self, req, fp, code, msg, headers): host = urlparse(req.full_url)[1] - retry = self.http_error_auth_reqed('www-authenticate', - host, req, headers) - self.reset_retry_count() - return retry + try: + return self.http_error_auth_reqed('www-authenticate', + host, req, headers) + finally: + self.reset_retry_count() class ProxyDigestAuthHandler(BaseHandler, AbstractDigestAuthHandler): @@ -1223,10 +1224,11 @@ class ProxyDigestAuthHandler(BaseHandler, AbstractDigestAuthHandler): def http_error_407(self, req, fp, code, msg, headers): host = req.host - retry = self.http_error_auth_reqed('proxy-authenticate', - host, req, headers) - self.reset_retry_count() - return retry + try: + return self.http_error_auth_reqed('proxy-authenticate', + host, req, headers) + finally: + self.reset_retry_count() class AbstractHTTPHandler(BaseHandler): diff --git a/Misc/NEWS.d/next/Library/2026-07-13-06-58-01.gh-issue-53907.MNKOKY.rst b/Misc/NEWS.d/next/Library/2026-07-13-06-58-01.gh-issue-53907.MNKOKY.rst new file mode 100644 index 000000000000000..5bce4834023f4c9 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-13-06-58-01.gh-issue-53907.MNKOKY.rst @@ -0,0 +1,4 @@ +Fix :class:`urllib.request.HTTPDigestAuthHandler` and +:class:`~urllib.request.ProxyDigestAuthHandler` not resetting their retry count +when authentication fails. The stale count made the handler reject valid +credentials on all subsequent requests.