From b6af7b75ac0530bb1f8223de32aee132593f77c7 Mon Sep 17 00:00:00 2001 From: Mitchell McKenzie Date: Thu, 10 Sep 2026 15:54:18 -0400 Subject: [PATCH 1/5] (fix) update handling of NotImplementedError to ensure a retry is signal properly --- .../google/auth/aio/transport/sessions.py | 1 - .../tests/transport/aio/test_sessions_mtls.py | 49 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/packages/google-auth/google/auth/aio/transport/sessions.py b/packages/google-auth/google/auth/aio/transport/sessions.py index f2ced1280e50..d67bf71e0a49 100644 --- a/packages/google-auth/google/auth/aio/transport/sessions.py +++ b/packages/google-auth/google/auth/aio/transport/sessions.py @@ -485,7 +485,6 @@ async def _recover_auth_state(): _LOGGER.debug( "Credentials do not implement refresh()." ) - return response except ( exceptions.RefreshError, getattr(exceptions, "InvalidOperation", Exception), diff --git a/packages/google-auth/tests/transport/aio/test_sessions_mtls.py b/packages/google-auth/tests/transport/aio/test_sessions_mtls.py index f6f1185a660e..f15aca7fcf45 100644 --- a/packages/google-auth/tests/transport/aio/test_sessions_mtls.py +++ b/packages/google-auth/tests/transport/aio/test_sessions_mtls.py @@ -1126,3 +1126,52 @@ async def slow_mtls_init(): assert not session._mtls_init_task.cancelled() assert session._is_mtls is True await session.close() + + @pytest.mark.asyncio + async def test_cert_rotation_credential_refresh_not_implemented_retries(self): + """Validate credentials that raise NotImplementedError on refresh() + still trigger a retry after mTLS reconfiguration, not return the 401.""" + mock_creds = mock.AsyncMock(spec=credentials.Credentials) + mock_creds.before_request = mock.AsyncMock(return_value=None) + mock_creds.refresh = mock.AsyncMock(side_effect=NotImplementedError) + + mock_resp_401 = mock.Mock() + mock_resp_401.status_code = http_client.UNAUTHORIZED + mock_resp_401.close = mock.AsyncMock() + + mock_resp_200 = mock.Mock() + mock_resp_200.status_code = http_client.OK + mock_resp_200.close = mock.AsyncMock() + + mock_auth_req = mock.AsyncMock(side_effect=[mock_resp_401, mock_resp_200]) + + session = sessions.AsyncAuthorizedSession( + mock_creds, auth_request=mock_auth_req + ) + session._is_mtls = True + session._cached_cert = b"old_cert" + + with ( + mock.patch( + "google.auth.aio.transport.mtls.check_parameters_for_unauthorized_response", + new_callable=mock.AsyncMock, + ) as mock_check, + mock.patch.object( + session, "configure_mtls_channel", new_callable=mock.AsyncMock + ) as mock_conf, + ): + mock_check.return_value = (b"new_cert", b"new_key", b"old_fp", b"new_fp") + + resp = await session.request( + "GET", "https://pubsub.mtls.googleapis.com/test" + ) + + # Validate that the handler falls through to `return None` + # on NotImplementedError in order to signal retry. + assert resp == mock_resp_200 + mock_conf.assert_called_once() + mock_creds.refresh.assert_called_once() + assert mock_auth_req.call_count == 2 + mock_resp_401.close.assert_called_once() + + await session.close() From 938571eb77035f93ed51651eefe7eb8dd12eec84 Mon Sep 17 00:00:00 2001 From: Mitchell McKenzie Date: Thu, 10 Sep 2026 15:54:18 -0400 Subject: [PATCH 2/5] (fix) updating the tests to use with instread of old syntax --- .../tests/transport/aio/test_sessions_mtls.py | 36 +++++++++---------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/packages/google-auth/tests/transport/aio/test_sessions_mtls.py b/packages/google-auth/tests/transport/aio/test_sessions_mtls.py index f15aca7fcf45..bb6b0bbf5066 100644 --- a/packages/google-auth/tests/transport/aio/test_sessions_mtls.py +++ b/packages/google-auth/tests/transport/aio/test_sessions_mtls.py @@ -1151,27 +1151,25 @@ async def test_cert_rotation_credential_refresh_not_implemented_retries(self): session._is_mtls = True session._cached_cert = b"old_cert" - with ( - mock.patch( - "google.auth.aio.transport.mtls.check_parameters_for_unauthorized_response", - new_callable=mock.AsyncMock, - ) as mock_check, - mock.patch.object( + with mock.patch( + "google.auth.aio.transport.mtls.check_parameters_for_unauthorized_response", + new_callable=mock.AsyncMock, + ) as mock_check: + with mock.patch.object( session, "configure_mtls_channel", new_callable=mock.AsyncMock - ) as mock_conf, - ): - mock_check.return_value = (b"new_cert", b"new_key", b"old_fp", b"new_fp") + ) as mock_conf: + mock_check.return_value = (b"new_cert", b"new_key", b"old_fp", b"new_fp") - resp = await session.request( - "GET", "https://pubsub.mtls.googleapis.com/test" - ) + resp = await session.request( + "GET", "https://pubsub.mtls.googleapis.com/test" + ) - # Validate that the handler falls through to `return None` - # on NotImplementedError in order to signal retry. - assert resp == mock_resp_200 - mock_conf.assert_called_once() - mock_creds.refresh.assert_called_once() - assert mock_auth_req.call_count == 2 - mock_resp_401.close.assert_called_once() + # Validate that the handler falls through to `return None` + # on NotImplementedError in order to signal retry. + assert resp == mock_resp_200 + mock_conf.assert_called_once() + mock_creds.refresh.assert_called_once() + assert mock_auth_req.call_count == 2 + mock_resp_401.close.assert_called_once() await session.close() From b6f93273842fa101234e3d2a4ca0200cf1770d91 Mon Sep 17 00:00:00 2001 From: Mitchell McKenzie Date: Thu, 10 Sep 2026 15:54:18 -0400 Subject: [PATCH 3/5] (fix) lint line length correction --- .../google-auth/tests/transport/aio/test_sessions_mtls.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/google-auth/tests/transport/aio/test_sessions_mtls.py b/packages/google-auth/tests/transport/aio/test_sessions_mtls.py index bb6b0bbf5066..9dd3b60f1e13 100644 --- a/packages/google-auth/tests/transport/aio/test_sessions_mtls.py +++ b/packages/google-auth/tests/transport/aio/test_sessions_mtls.py @@ -1158,7 +1158,12 @@ async def test_cert_rotation_credential_refresh_not_implemented_retries(self): with mock.patch.object( session, "configure_mtls_channel", new_callable=mock.AsyncMock ) as mock_conf: - mock_check.return_value = (b"new_cert", b"new_key", b"old_fp", b"new_fp") + mock_check.return_value = ( + b"new_cert", + b"new_key", + b"old_fp", + b"new_fp", + ) resp = await session.request( "GET", "https://pubsub.mtls.googleapis.com/test" From c49b9786be839c7d606f830b9a8cf8d7d8e1838b Mon Sep 17 00:00:00 2001 From: Mitchell McKenzie Date: Fri, 11 Sep 2026 08:51:29 -0400 Subject: [PATCH 4/5] test(auth): assert reconfigure callback and refresh args in NotImplementedError retry test --- .../google-auth/tests/transport/aio/test_sessions_mtls.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/google-auth/tests/transport/aio/test_sessions_mtls.py b/packages/google-auth/tests/transport/aio/test_sessions_mtls.py index 9dd3b60f1e13..2d6e558ae9bf 100644 --- a/packages/google-auth/tests/transport/aio/test_sessions_mtls.py +++ b/packages/google-auth/tests/transport/aio/test_sessions_mtls.py @@ -1173,7 +1173,13 @@ async def test_cert_rotation_credential_refresh_not_implemented_retries(self): # on NotImplementedError in order to signal retry. assert resp == mock_resp_200 mock_conf.assert_called_once() - mock_creds.refresh.assert_called_once() + cb = ( + mock_conf.call_args.args[0] + if mock_conf.call_args.args + else mock_conf.call_args.kwargs["client_cert_callback"] + ) + assert cb() == (b"new_cert", b"new_key") + mock_creds.refresh.assert_called_once_with(mock_auth_req) assert mock_auth_req.call_count == 2 mock_resp_401.close.assert_called_once() From 9bb91ea73001108963664757c31a748f3099ffe1 Mon Sep 17 00:00:00 2001 From: Andy Zhao Date: Mon, 14 Sep 2026 18:46:43 +0000 Subject: [PATCH 5/5] fix(auth): guard NotImplementedError 401 retry on mTLS reconfiguration --- .../google/auth/aio/transport/sessions.py | 8 ++ .../tests/transport/aio/test_sessions_mtls.py | 123 ++++++++++++++++++ 2 files changed, 131 insertions(+) diff --git a/packages/google-auth/google/auth/aio/transport/sessions.py b/packages/google-auth/google/auth/aio/transport/sessions.py index d67bf71e0a49..232acdf2ba65 100644 --- a/packages/google-auth/google/auth/aio/transport/sessions.py +++ b/packages/google-auth/google/auth/aio/transport/sessions.py @@ -165,6 +165,7 @@ def __init__( self._auth_request = _auth_request self._mtls_rotation_lock: Optional[asyncio.Lock] = None self._mtls_check_counter = 0 + self._mtls_reconfig_counter = 0 self._refresh_lock: Optional[asyncio.Lock] = None self._refresh_counter = 0 @@ -326,6 +327,7 @@ async def request( start_time = time.monotonic() refresh_counter_at_error = self._refresh_counter check_counter_at_error = self._mtls_check_counter + reconfig_counter_at_error = self._mtls_reconfig_counter async with timeout_guard(max_allowed_time) as with_timeout: await with_timeout( # Note: before_request will attempt to refresh credentials if expired. @@ -444,6 +446,7 @@ async def _recover_auth_state(): call_key_bytes, ) ) + self._mtls_reconfig_counter += 1 except Exception as e: _LOGGER.error( "Failed to reconfigure mTLS channel: %s", @@ -485,6 +488,11 @@ async def _recover_auth_state(): _LOGGER.debug( "Credentials do not implement refresh()." ) + if ( + self._mtls_reconfig_counter + <= reconfig_counter_at_error + ): + return response except ( exceptions.RefreshError, getattr(exceptions, "InvalidOperation", Exception), diff --git a/packages/google-auth/tests/transport/aio/test_sessions_mtls.py b/packages/google-auth/tests/transport/aio/test_sessions_mtls.py index 2d6e558ae9bf..5b66f392c1d8 100644 --- a/packages/google-auth/tests/transport/aio/test_sessions_mtls.py +++ b/packages/google-auth/tests/transport/aio/test_sessions_mtls.py @@ -1184,3 +1184,126 @@ async def test_cert_rotation_credential_refresh_not_implemented_retries(self): mock_resp_401.close.assert_called_once() await session.close() + + @pytest.mark.asyncio + async def test_credential_refresh_not_implemented_no_retry_on_non_mtls(self): + """Validate credentials raising NotImplementedError on refresh do NOT + retry when the request is on a non-mTLS endpoint.""" + mock_creds = mock.AsyncMock(spec=credentials.Credentials) + mock_creds.before_request = mock.AsyncMock(return_value=None) + mock_creds.refresh = mock.AsyncMock(side_effect=NotImplementedError) + + mock_resp_401 = mock.Mock() + mock_resp_401.status_code = http_client.UNAUTHORIZED + mock_resp_401.close = mock.AsyncMock() + + mock_auth_req = mock.AsyncMock(return_value=mock_resp_401) + + session = sessions.AsyncAuthorizedSession( + mock_creds, auth_request=mock_auth_req + ) + + resp = await session.request("GET", "https://pubsub.googleapis.com/test") + + assert resp == mock_resp_401 + assert mock_auth_req.call_count == 1 + await session.close() + + @pytest.mark.asyncio + async def test_credential_refresh_not_implemented_no_retry_when_cert_not_rotated( + self, + ): + """Validate credentials raising NotImplementedError on refresh do NOT + retry when the mTLS certificate has not rotated.""" + mock_creds = mock.AsyncMock(spec=credentials.Credentials) + mock_creds.before_request = mock.AsyncMock(return_value=None) + mock_creds.refresh = mock.AsyncMock(side_effect=NotImplementedError) + + mock_resp_401 = mock.Mock() + mock_resp_401.status_code = http_client.UNAUTHORIZED + mock_resp_401.close = mock.AsyncMock() + + mock_auth_req = mock.AsyncMock(return_value=mock_resp_401) + + session = sessions.AsyncAuthorizedSession( + mock_creds, auth_request=mock_auth_req + ) + session._is_mtls = True + session._cached_cert = b"current_cert" + + with mock.patch( + "google.auth.aio.transport.mtls.check_parameters_for_unauthorized_response", + new_callable=mock.AsyncMock, + ) as mock_check: + mock_check.return_value = ( + b"current_cert", + b"current_key", + b"same_fp", + b"same_fp", + ) + + resp = await session.request( + "GET", "https://pubsub.mtls.googleapis.com/test" + ) + + assert resp == mock_resp_401 + assert mock_auth_req.call_count == 1 + + await session.close() + + @pytest.mark.asyncio + async def test_credential_refresh_not_implemented_concurrent_rotation_retries( + self, + ): + """Validate that concurrent requests hitting 401 during rotation both retry + and succeed when credentials raise NotImplementedError.""" + mock_creds = mock.AsyncMock(spec=credentials.Credentials) + mock_creds.before_request = mock.AsyncMock(return_value=None) + mock_creds.refresh = mock.AsyncMock(side_effect=NotImplementedError) + + mock_resp_401_a = mock.Mock( + status_code=http_client.UNAUTHORIZED, close=mock.AsyncMock() + ) + mock_resp_401_b = mock.Mock( + status_code=http_client.UNAUTHORIZED, close=mock.AsyncMock() + ) + mock_resp_200_a = mock.Mock(status_code=http_client.OK, close=mock.AsyncMock()) + mock_resp_200_b = mock.Mock(status_code=http_client.OK, close=mock.AsyncMock()) + + mock_auth_req = mock.AsyncMock( + side_effect=[ + mock_resp_401_a, + mock_resp_401_b, + mock_resp_200_a, + mock_resp_200_b, + ] + ) + + session = sessions.AsyncAuthorizedSession( + mock_creds, auth_request=mock_auth_req + ) + session._is_mtls = True + session._cached_cert = b"old_cert" + + with mock.patch( + "google.auth.aio.transport.mtls.check_parameters_for_unauthorized_response", + new_callable=mock.AsyncMock, + ) as mock_check: + with mock.patch.object( + session, "configure_mtls_channel", new_callable=mock.AsyncMock + ): + mock_check.return_value = ( + b"new_cert", + b"new_key", + b"old_fp", + b"new_fp", + ) + + resps = await asyncio.gather( + session.request("GET", "https://pubsub.mtls.googleapis.com/test"), + session.request("GET", "https://pubsub.mtls.googleapis.com/test"), + ) + assert resps == [mock_resp_200_a, mock_resp_200_b] + assert mock_auth_req.call_count == 4 + + await session.close()