From 226afd47fb4b6c3a774c66b983556a690e94f032 Mon Sep 17 00:00:00 2001 From: Radhika Agrawal Date: Thu, 10 Sep 2026 18:21:46 +0000 Subject: [PATCH 01/11] fix: address concurrency crashes, state desynchronization, and test coverage in async mTLS sessions - Wrap await self._mtls_init_task in asyncio.shield in configure_mtls_channel to prevent external cancellations from destroying the init task - Catch asyncio.CancelledError in request() when _mtls_init_task was cancelled - Force credential refresh in _recover_auth_state when certificate rotation occurs, and conditionally increment _mtls_check_counter on check success - Support reconfiguration in configure_mtls_channel when task is None or done without unsafe task variable resets - Update close() to safely drain and close _old_auth_requests and _auth_request in a robust try...finally structure - Atomically update self._is_mtls and self._cached_cert upon _auth_request swap - Ensure 401 response is closed on timeout and update test assertions - Restore 5 unit tests for configure_mtls_channel and add e2e rotation test --- .../google/auth/aio/transport/sessions.py | 73 ++--- .../tests/transport/aio/test_sessions_mtls.py | 249 ++++++++++++++++-- 2 files changed, 270 insertions(+), 52 deletions(-) diff --git a/packages/google-auth/google/auth/aio/transport/sessions.py b/packages/google-auth/google/auth/aio/transport/sessions.py index f2ced1280e50..0abfcdb3a645 100644 --- a/packages/google-auth/google/auth/aio/transport/sessions.py +++ b/packages/google-auth/google/auth/aio/transport/sessions.py @@ -193,7 +193,7 @@ async def configure_mtls_channel(self, client_cert_callback=None): google.auth.exceptions.MutualTLSChannelError: If mutual TLS channel creation failed for any reason. """ - if self._mtls_init_task is None: + if self._mtls_init_task is None or self._mtls_init_task.done(): self._client_cert_callback = client_cert_callback async def _do_configure(): @@ -224,10 +224,12 @@ async def _do_configure(): old_auth_request = self._auth_request self._auth_request = AiohttpRequest(session=new_session) + self._is_mtls = True + self._cached_cert = cert self._old_auth_requests.append(old_auth_request) while len(self._old_auth_requests) > 2: - oldest_auth_request = self._old_auth_requests[0] + oldest_auth_request = self._old_auth_requests.pop(0) try: if hasattr(oldest_auth_request, "close"): res = oldest_auth_request.close() @@ -235,10 +237,11 @@ async def _do_configure(): await res except Exception: pass - self._old_auth_requests.pop(0) else: is_mtls = False + self._is_mtls = False + self._cached_cert = None warnings.warn( "Attempted to establish mTLS, but a custom async transport was provided. " "google-auth cannot automatically configure custom transports for mTLS. " @@ -247,24 +250,19 @@ async def _do_configure(): "using Certificate-Bound Tokens.", UserWarning, ) - - self._is_mtls = is_mtls - if is_mtls: - self._cached_cert = cert else: + self._is_mtls = False self._cached_cert = None except Exception as caught_exc: + self._is_mtls = False + self._cached_cert = None new_exc = exceptions.MutualTLSChannelError(caught_exc) raise new_exc from caught_exc self._mtls_init_task = asyncio.create_task(_do_configure()) - try: - return await self._mtls_init_task - except BaseException: - self._mtls_init_task = None - raise + return await asyncio.shield(self._mtls_init_task) async def request( self, @@ -319,6 +317,11 @@ async def request( # Suppress all exceptions from the background mTLS initialization task, # allowing the request to fail naturally elsewhere. pass + except asyncio.CancelledError: + if self._mtls_init_task.cancelled(): + pass + else: + raise retries = _exponential_backoff.AsyncExponentialBackoff( total_attempts=total_attempts, ) @@ -371,6 +374,7 @@ async def request( ) async def _recover_auth_state(): + channel_reconfigured = False is_mtls_endpoint = False if self._is_mtls: hostname = urllib.parse.urlsplit(url).hostname @@ -394,6 +398,7 @@ async def _recover_auth_state(): ): pass else: + check_passed = False try: ( call_cert_bytes, @@ -404,6 +409,7 @@ async def _recover_auth_state(): self._cached_cert, self._client_cert_callback, ) + check_passed = True except ( exceptions.ClientCertError, exceptions.MutualTLSChannelError, @@ -429,21 +435,13 @@ async def _recover_auth_state(): "Client certificate has changed, reconfiguring mTLS " "channel." ) - if self._mtls_init_task is not None: - if ( - not self._mtls_init_task.done() - ): - try: - await self._mtls_init_task - except Exception: - pass - self._mtls_init_task = None await self.configure_mtls_channel( lambda: ( call_cert_bytes, call_key_bytes, ) ) + channel_reconfigured = True except Exception as e: _LOGGER.error( "Failed to reconfigure mTLS channel: %s", @@ -467,14 +465,14 @@ async def _recover_auth_state(): "Skipping reconfiguration of mTLS channel because the client" " certificate has not changed." ) - # Always increment so waiting tasks skip the check block - self._mtls_check_counter += 1 + if check_passed: + self._mtls_check_counter += 1 if self._refresh_lock is None: self._refresh_lock = asyncio.Lock() async with self._refresh_lock: # Check if another task already refreshed credentials while we were waiting - if self._refresh_counter > refresh_counter_at_error: + if not channel_reconfigured and self._refresh_counter > refresh_counter_at_error: _LOGGER.debug( "Credentials were already refreshed by a concurrent task. Skipping duplicate refresh." ) @@ -821,19 +819,16 @@ async def close(self) -> None: """ Close the underlying auth request session. """ - if self._mtls_init_task and not self._mtls_init_task.done(): - self._mtls_init_task.cancel() - try: - await self._mtls_init_task - except asyncio.CancelledError: - pass try: - if hasattr(self._auth_request, "close"): - res = self._auth_request.close() - if inspect.isawaitable(res): - await res + if self._mtls_init_task and not self._mtls_init_task.done(): + self._mtls_init_task.cancel() + try: + await self._mtls_init_task + except (Exception, asyncio.CancelledError): + pass finally: - for old_request in self._old_auth_requests: + while self._old_auth_requests: + old_request = self._old_auth_requests.pop(0) try: if hasattr(old_request, "close"): res = old_request.close() @@ -841,4 +836,10 @@ async def close(self) -> None: await res except Exception: pass - self._old_auth_requests.clear() + try: + if hasattr(self._auth_request, "close"): + res = self._auth_request.close() + if inspect.isawaitable(res): + await res + except Exception: + pass 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..7cbf61cf2e7a 100644 --- a/packages/google-auth/tests/transport/aio/test_sessions_mtls.py +++ b/packages/google-auth/tests/transport/aio/test_sessions_mtls.py @@ -131,6 +131,125 @@ async def test_configure_mtls_channel_invalid_fields(self): await session.configure_mtls_channel() await session.close() + @pytest.mark.asyncio + async def test_configure_mtls_channel_mock_callback(self): + callback = mock.AsyncMock(return_value=(b"cert", b"key")) + with ( + mock.patch( + "google.auth.transport._mtls_helper.check_use_client_cert", + return_value=True, + ), + mock.patch( + "google.auth.aio.transport.mtls.get_client_cert_and_key", + return_value=(True, b"cert", b"key"), + ), + mock.patch( + "google.auth.aio.transport.mtls.make_client_cert_ssl_context", + return_value=mock.Mock(spec=ssl.SSLContext), + ) as mock_make_context, + mock.patch("aiohttp.TCPConnector"), + mock.patch("aiohttp.ClientSession"), + ): + mock_creds = mock.AsyncMock(spec=credentials.Credentials) + session = sessions.AsyncAuthorizedSession(mock_creds) + await session.configure_mtls_channel(callback) + assert session.is_mtls is True + assert session._cached_cert == b"cert" + mock_make_context.assert_called_once_with(b"cert", b"key") + await session.close() + + @pytest.mark.asyncio + async def test_configure_mtls_channel_custom_request(self): + custom_req = mock.AsyncMock(spec=transport.Request) + mock_creds = mock.AsyncMock(spec=credentials.Credentials) + session = sessions.AsyncAuthorizedSession(mock_creds, auth_request=custom_req) + with ( + mock.patch( + "google.auth.transport._mtls_helper.check_use_client_cert", + return_value=True, + ), + mock.patch( + "google.auth.aio.transport.mtls.get_client_cert_and_key", + return_value=(True, b"cert", b"key"), + ), + pytest.warns( + UserWarning, + match="Attempted to establish mTLS, but a custom async transport was provided", + ), + ): + await session.configure_mtls_channel() + assert session.is_mtls is False + assert session._cached_cert == None + await session.close() + + @pytest.mark.asyncio + async def test_configure_mtls_channel_exception_resets_flag(self): + mock_creds = mock.AsyncMock(spec=credentials.Credentials) + session = sessions.AsyncAuthorizedSession(mock_creds) + session._is_mtls = True + session._cached_cert = b"old_cert" + with ( + mock.patch( + "google.auth.transport._mtls_helper.check_use_client_cert", + return_value=True, + ), + mock.patch( + "google.auth.aio.transport.mtls.get_client_cert_and_key", + side_effect=Exception("Disk failure"), + ), + pytest.raises(exceptions.MutualTLSChannelError), + ): + await session.configure_mtls_channel() + assert session.is_mtls is False + assert session._cached_cert == None + await session.close() + + @pytest.mark.asyncio + async def test_configure_mtls_channel_transport_error_resets_flag(self): + mock_creds = mock.AsyncMock(spec=credentials.Credentials) + session = sessions.AsyncAuthorizedSession(mock_creds) + with ( + mock.patch( + "google.auth.transport._mtls_helper.check_use_client_cert", + return_value=True, + ), + mock.patch( + "google.auth.aio.transport.mtls.get_client_cert_and_key", + return_value=(True, b"cert", b"key"), + ), + mock.patch( + "google.auth.aio.transport.mtls.make_client_cert_ssl_context", + return_value=mock.Mock(spec=ssl.SSLContext), + ), + mock.patch("aiohttp.ClientSession", side_effect=Exception("Session error")), + pytest.raises(exceptions.MutualTLSChannelError), + ): + await session.configure_mtls_channel() + assert session.is_mtls is False + assert session._cached_cert == None + await session.close() + + @pytest.mark.asyncio + async def test_configure_mtls_channel_atomic_on_exception(self): + mock_creds = mock.AsyncMock(spec=credentials.Credentials) + session = sessions.AsyncAuthorizedSession(mock_creds) + orig_req = session._auth_request + with ( + mock.patch( + "google.auth.transport._mtls_helper.check_use_client_cert", + return_value=True, + ), + mock.patch( + "google.auth.aio.transport.mtls.get_client_cert_and_key", + side_effect=RuntimeError("Fatal error"), + ), + pytest.raises(exceptions.MutualTLSChannelError), + ): + await session.configure_mtls_channel() + assert session._auth_request is orig_req + assert session.is_mtls is False + await session.close() + @pytest.mark.asyncio async def test_configure_mtls_channel_close_exception_does_not_abort(self): """Tests that an exception in old_auth_request.close() during eviction does not abort configuration.""" @@ -888,13 +1007,17 @@ async def dummy_completed(): session._mtls_init_task = initial_task # Pre-populate completed task new_cert = b"new_cert" new_key = b"new_key" + + async def fake_configure(cb=None): + session._mtls_init_task = asyncio.create_task(dummy_completed()) + 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 + session, "configure_mtls_channel", side_effect=fake_configure ) as mock_conf, ): mock_check.return_value = (new_cert, new_key, b"old_fp", b"new_fp") @@ -918,21 +1041,36 @@ async def test_401_retry_raises_timeout_before_refresh(self): """ mock_creds = mock.AsyncMock(spec=credentials.Credentials) mock_auth_request = mock.AsyncMock(spec=transport.Request) - mock_resp_401 = mock.Mock(spec=transport.Response, status_code=401) - current_time = 0.0 + mock_resp_401 = mock.Mock(spec=transport.Response) + mock_resp_401.close = mock.AsyncMock() + + status_access_count = 0 + + def get_status(): + nonlocal status_access_count + status_access_count += 1 + return 401 + + type(mock_resp_401).status_code = property(lambda self: get_status()) - # When the 401 request completes, advance time past max_allowed_time async def fake_auth_request(*args, **kwargs): - nonlocal current_time - current_time = 100.0 # Expire timeout before refresh starts return mock_resp_401 mock_auth_request.side_effect = fake_auth_request session = sessions.AsyncAuthorizedSession( mock_creds, auth_request=mock_auth_request ) - with mock.patch("time.monotonic", side_effect=lambda: current_time): - with pytest.raises(exceptions.TimeoutError): + + def mock_time(): + if status_access_count >= 2: + return 100.0 + return 0.1 + + with mock.patch("time.monotonic", side_effect=mock_time): + with pytest.raises( + exceptions.TimeoutError, + match="Timeout exceeded before credential refresh could begin", + ): await session.request( "GET", "https://example.com", max_allowed_time=1.0 ) @@ -949,21 +1087,29 @@ async def test_401_retry_raises_timeout_before_subsequent_retry(self): mock_creds = mock.AsyncMock(spec=credentials.Credentials) mock_auth_request = mock.AsyncMock(spec=transport.Request) mock_resp_401 = mock.Mock(spec=transport.Response, status_code=401) + mock_resp_401.close = mock.AsyncMock() mock_auth_request.return_value = mock_resp_401 - current_time = 0.0 - # Allow initial request to proceed at t=0.0, but expire timeout during refresh async def fake_refresh(auth_request): - nonlocal current_time - current_time = 100.0 # Expire timeout during refresh before retry return None mock_creds.refresh = mock.AsyncMock(side_effect=fake_refresh) session = sessions.AsyncAuthorizedSession( mock_creds, auth_request=mock_auth_request ) - with mock.patch("time.monotonic", side_effect=lambda: current_time): - with pytest.raises(exceptions.TimeoutError): + + time_calls = [0.0, 0.0, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 100.0] + + def mock_time(): + if time_calls: + return time_calls.pop(0) + return 100.0 + + with mock.patch("time.monotonic", side_effect=mock_time): + with pytest.raises( + exceptions.TimeoutError, + match="Timeout exceeded before retrying the request", + ): await session.request( "GET", "https://example.com", max_allowed_time=1.0 ) @@ -1029,8 +1175,8 @@ async def slow_failing_check(*args, **kwargs): ) assert results == [mock_resp_200_1, mock_resp_200_2] - assert mock_check.call_count == 1 - assert session._mtls_check_counter == 1 + assert mock_check.call_count == 2 + assert session._mtls_check_counter == 0 assert mock_conf.call_count == 0 assert mock_creds.refresh.call_count == 1 @@ -1126,3 +1272,74 @@ 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_401_mtls_rotation_e2e_unmocked_configure(self): + """End-to-end 401 recovery test with unmocked configure_mtls_channel.""" + mock_creds = mock.AsyncMock(spec=credentials.Credentials) + mock_creds.before_request = mock.AsyncMock(return_value=None) + mock_creds.refresh = mock.AsyncMock(return_value=None) + + cert_v1 = b"cert_v1" + key_v1 = b"key_v1" + cert_v2 = b"cert_v2" + key_v2 = b"key_v2" + + certs_queue = [(True, cert_v1, key_v1), (True, cert_v2, key_v2)] + + async def mock_get_cert(cb=None): + if certs_queue: + return certs_queue.pop(0) + return (True, cert_v2, key_v2) + + with ( + mock.patch( + "google.auth.transport._mtls_helper.check_use_client_cert", + return_value=True, + ), + mock.patch( + "google.auth.aio.transport.mtls.get_client_cert_and_key", + side_effect=mock_get_cert, + ), + mock.patch( + "google.auth.aio.transport.mtls.make_client_cert_ssl_context", + return_value=mock.Mock(spec=ssl.SSLContext), + ), + mock.patch("aiohttp.TCPConnector"), + mock.patch("aiohttp.ClientSession"), + ): + session = sessions.AsyncAuthorizedSession(mock_creds) + await session.configure_mtls_channel() + assert session.is_mtls is True + assert session._cached_cert == cert_v1 + first_auth_req = session._auth_request + + 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() + + with ( + mock.patch( + "google.auth.aio.transport.mtls.check_parameters_for_unauthorized_response", + new_callable=mock.AsyncMock, + return_value=(cert_v2, key_v2, b"fp1", b"fp2"), + ), + mock.patch.object( + sessions.AiohttpRequest, + "__call__", + side_effect=[mock_resp_401, mock_resp_200], + ), + ): + resp = await session.request( + "GET", "https://pubsub.mtls.googleapis.com/test" + ) + assert resp.status_code == 200 + assert session.is_mtls is True + assert session._cached_cert == cert_v2 + assert session._auth_request is not first_auth_req + assert mock_creds.refresh.call_count == 1 + await session.close() From c9ec3946c92377481520c33cf23617e452c32c43 Mon Sep 17 00:00:00 2001 From: agrawalradhika-cell Date: Thu, 10 Sep 2026 11:34:12 -0700 Subject: [PATCH 02/11] Update packages/google-auth/google/auth/aio/transport/sessions.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/google-auth/google/auth/aio/transport/sessions.py | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/google-auth/google/auth/aio/transport/sessions.py b/packages/google-auth/google/auth/aio/transport/sessions.py index 0abfcdb3a645..0ddca500df53 100644 --- a/packages/google-auth/google/auth/aio/transport/sessions.py +++ b/packages/google-auth/google/auth/aio/transport/sessions.py @@ -435,6 +435,7 @@ async def _recover_auth_state(): "Client certificate has changed, reconfiguring mTLS " "channel." ) + self._mtls_init_task = None await self.configure_mtls_channel( lambda: ( call_cert_bytes, From 2f7baa5a6a268850d2c2ca128bbe84955568794e Mon Sep 17 00:00:00 2001 From: agrawalradhika-cell Date: Thu, 10 Sep 2026 11:35:09 -0700 Subject: [PATCH 03/11] Update packages/google-auth/google/auth/aio/transport/sessions.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/google-auth/google/auth/aio/transport/sessions.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/google-auth/google/auth/aio/transport/sessions.py b/packages/google-auth/google/auth/aio/transport/sessions.py index 0ddca500df53..8781563238f9 100644 --- a/packages/google-auth/google/auth/aio/transport/sessions.py +++ b/packages/google-auth/google/auth/aio/transport/sessions.py @@ -255,8 +255,6 @@ async def _do_configure(): self._cached_cert = None except Exception as caught_exc: - self._is_mtls = False - self._cached_cert = None new_exc = exceptions.MutualTLSChannelError(caught_exc) raise new_exc from caught_exc From 0ffd85f4f7bc52d1c5582dd29297b1cee9740ab6 Mon Sep 17 00:00:00 2001 From: Radhika Agrawal Date: Thu, 10 Sep 2026 18:38:12 +0000 Subject: [PATCH 04/11] fix(auth): fix fingerprint comparison on empty cached cert, catch TypeError in cert check, and reference InvalidOperation directly - Match synchronous behavior in mtls.py by setting cached_fingerprint = current_fingerprint when cached_cert is falsy to prevent spurious mTLS reconfigurations on 401 - Catch TypeError in _recover_auth_state parameter checks and update warning log to reflect fallback to credential refresh and retry - Reference exceptions.InvalidOperation directly in refresh exception handler instead of getattr fallback - Add unit tests for TypeError fallback, empty cached cert comparison, and InvalidOperation handling --- .../google/auth/aio/transport/mtls.py | 2 +- .../google/auth/aio/transport/sessions.py | 17 ++- .../tests/transport/aio/test_mtls.py | 4 +- .../tests/transport/aio/test_sessions_mtls.py | 142 +++++++++++++++++- 4 files changed, 148 insertions(+), 17 deletions(-) diff --git a/packages/google-auth/google/auth/aio/transport/mtls.py b/packages/google-auth/google/auth/aio/transport/mtls.py index 267a1e401ff0..47adb3df7d5b 100644 --- a/packages/google-auth/google/auth/aio/transport/mtls.py +++ b/packages/google-auth/google/auth/aio/transport/mtls.py @@ -211,7 +211,7 @@ def _fetch_fingerprints(): cached_cert ) else: - cached_fingerprint = None + cached_fingerprint = current_fingerprint return cached_fingerprint, current_fingerprint cached_fingerprint, current_cert_fingerprint = await _run_in_executor( diff --git a/packages/google-auth/google/auth/aio/transport/sessions.py b/packages/google-auth/google/auth/aio/transport/sessions.py index 8781563238f9..d85ee18d769f 100644 --- a/packages/google-auth/google/auth/aio/transport/sessions.py +++ b/packages/google-auth/google/auth/aio/transport/sessions.py @@ -255,6 +255,8 @@ async def _do_configure(): self._cached_cert = None except Exception as caught_exc: + self._is_mtls = False + self._cached_cert = None new_exc = exceptions.MutualTLSChannelError(caught_exc) raise new_exc from caught_exc @@ -413,10 +415,12 @@ async def _recover_auth_state(): exceptions.MutualTLSChannelError, OSError, ValueError, + TypeError, ImportError, ) as e: _LOGGER.warning( - "Failed to check client certificate parameters: %s. Proceeding with original response.", + "Failed to check client certificate parameters: %s. " + "Falling back to credential refresh and retry.", e, ) else: @@ -433,13 +437,12 @@ async def _recover_auth_state(): "Client certificate has changed, reconfiguring mTLS " "channel." ) - self._mtls_init_task = None await self.configure_mtls_channel( lambda: ( - call_cert_bytes, - call_key_bytes, - ) - ) + call_cert_bytes, + call_key_bytes, + ) + ) channel_reconfigured = True except Exception as e: _LOGGER.error( @@ -485,7 +488,7 @@ async def _recover_auth_state(): return response except ( exceptions.RefreshError, - getattr(exceptions, "InvalidOperation", Exception), + exceptions.InvalidOperation, ) as e: _LOGGER.debug( "Credential refresh failed, returning 401 response. Error: %s", diff --git a/packages/google-auth/tests/transport/aio/test_mtls.py b/packages/google-auth/tests/transport/aio/test_mtls.py index d0ff6b749719..585ed1f3ef9c 100644 --- a/packages/google-auth/tests/transport/aio/test_mtls.py +++ b/packages/google-auth/tests/transport/aio/test_mtls.py @@ -159,9 +159,9 @@ def callback(): assert cert == CERT_BYTES assert key == KEY_BYTES - assert cached_fp is None + assert cached_fp == "FINGERPRINT_CURRENT" assert current_fp == "FINGERPRINT_CURRENT" - assert cached_fp != current_fp + assert cached_fp == current_fp mock_get_cached.assert_not_called() 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 7cbf61cf2e7a..7d03dbbee120 100644 --- a/packages/google-auth/tests/transport/aio/test_sessions_mtls.py +++ b/packages/google-auth/tests/transport/aio/test_sessions_mtls.py @@ -1041,17 +1041,20 @@ async def test_401_retry_raises_timeout_before_refresh(self): """ mock_creds = mock.AsyncMock(spec=credentials.Credentials) mock_auth_request = mock.AsyncMock(spec=transport.Request) - mock_resp_401 = mock.Mock(spec=transport.Response) - mock_resp_401.close = mock.AsyncMock() status_access_count = 0 - def get_status(): - nonlocal status_access_count - status_access_count += 1 - return 401 + class _CustomResponse: + def __init__(self): + self.close = mock.AsyncMock() + + @property + def status_code(self): + nonlocal status_access_count + status_access_count += 1 + return 401 - type(mock_resp_401).status_code = property(lambda self: get_status()) + mock_resp_401 = _CustomResponse() async def fake_auth_request(*args, **kwargs): return mock_resp_401 @@ -1343,3 +1346,128 @@ async def mock_get_cert(cb=None): assert session._auth_request is not first_auth_req assert mock_creds.refresh.call_count == 1 await session.close() + + @pytest.mark.asyncio + async def test_401_cert_check_type_error_falls_back_to_refresh(self): + """Verifies that TypeError in cert check logs warning and falls back to refresh and retry.""" + mock_creds = mock.AsyncMock(spec=credentials.Credentials) + mock_creds.before_request = mock.AsyncMock(return_value=None) + mock_creds.refresh = mock.AsyncMock(return_value=None) + + 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"some_cert" + + with ( + mock.patch( + "google.auth.aio.transport.mtls.check_parameters_for_unauthorized_response", + new_callable=mock.AsyncMock, + side_effect=TypeError("Callback returned invalid type"), + ), + mock.patch.object( + session, "configure_mtls_channel", new_callable=mock.AsyncMock + ) as mock_conf, + mock.patch.object(sessions._LOGGER, "warning") as mock_warn, + ): + resp = await session.request( + "GET", "https://pubsub.mtls.googleapis.com/test" + ) + assert resp == mock_resp_200 + mock_conf.assert_not_called() + mock_creds.refresh.assert_called_once() + assert any( + "Falling back to credential refresh and retry." in str(call) + for call in mock_warn.call_args_list + ) + await session.close() + + @pytest.mark.asyncio + async def test_401_cert_check_without_cached_cert_skips_reconfiguration(self): + """Verifies that when cached_cert is None, cert check produces equal fingerprints and skips reconfiguration.""" + mock_creds = mock.AsyncMock(spec=credentials.Credentials) + mock_creds.before_request = mock.AsyncMock(return_value=None) + mock_creds.refresh = mock.AsyncMock(return_value=None) + + 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 = None + + with ( + mock.patch( + "google.auth.aio.transport.mtls.get_client_cert_and_key", + new_callable=mock.AsyncMock, + return_value=(True, b"cert_bytes", b"key_bytes"), + ), + mock.patch( + "google.auth._agent_identity_utils.parse_certificate" + ), + mock.patch( + "google.auth._agent_identity_utils.calculate_certificate_fingerprint", + return_value="FINGERPRINT_1", + ), + mock.patch.object( + session, "configure_mtls_channel", new_callable=mock.AsyncMock + ) as mock_conf, + mock.patch.object(sessions._LOGGER, "info") as mock_info, + ): + resp = await session.request( + "GET", "https://pubsub.mtls.googleapis.com/test" + ) + assert resp == mock_resp_200 + mock_conf.assert_not_called() + mock_creds.refresh.assert_called_once() + assert any( + "certificate has not changed" in str(call) + for call in mock_info.call_args_list + ) + await session.close() + + @pytest.mark.asyncio + async def test_401_refresh_raises_invalid_operation_returns_401(self): + """Verifies that exceptions.InvalidOperation during refresh is caught and returns the 401 response.""" + mock_creds = mock.AsyncMock(spec=credentials.Credentials) + mock_creds.before_request = mock.AsyncMock(return_value=None) + mock_creds.refresh = mock.AsyncMock( + side_effect=exceptions.InvalidOperation("Invalid operation") + ) + + 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://example.com" + ) + assert resp == mock_resp_401 + mock_creds.refresh.assert_called_once() + await session.close() From 367585814e7fc6f1d9dfd9e74ca6386a4a57ef25 Mon Sep 17 00:00:00 2001 From: Radhika Agrawal Date: Thu, 10 Sep 2026 19:40:53 +0000 Subject: [PATCH 05/11] fix(auth): make configure_mtls_channel idempotent for repeated default calls and preserve metadata state on error - Distinguish between idempotent default calls and explicit reconfigurations in configure_mtls_channel - Preserve self._is_mtls and self._cached_cert on configuration failure to keep metadata in sync with the active _auth_request - Add unit tests verifying configure_mtls_channel idempotency and state preservation --- .../google/auth/aio/transport/sessions.py | 17 +++- .../tests/transport/aio/test_sessions_mtls.py | 87 ++++++++++++++++++- 2 files changed, 97 insertions(+), 7 deletions(-) diff --git a/packages/google-auth/google/auth/aio/transport/sessions.py b/packages/google-auth/google/auth/aio/transport/sessions.py index d85ee18d769f..86368bdfc34c 100644 --- a/packages/google-auth/google/auth/aio/transport/sessions.py +++ b/packages/google-auth/google/auth/aio/transport/sessions.py @@ -193,7 +193,20 @@ async def configure_mtls_channel(self, client_cert_callback=None): google.auth.exceptions.MutualTLSChannelError: If mutual TLS channel creation failed for any reason. """ - if self._mtls_init_task is None or self._mtls_init_task.done(): + is_explicit_reconfig = ( + client_cert_callback is not None + and client_cert_callback != self._client_cert_callback + ) + task_failed = ( + self._mtls_init_task is not None + and self._mtls_init_task.done() + and ( + self._mtls_init_task.cancelled() + or self._mtls_init_task.exception() is not None + ) + ) + + if self._mtls_init_task is None or is_explicit_reconfig or task_failed: self._client_cert_callback = client_cert_callback async def _do_configure(): @@ -255,8 +268,6 @@ async def _do_configure(): self._cached_cert = None except Exception as caught_exc: - self._is_mtls = False - self._cached_cert = None new_exc = exceptions.MutualTLSChannelError(caught_exc) raise new_exc from caught_exc 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 7d03dbbee120..d8e12494b73c 100644 --- a/packages/google-auth/tests/transport/aio/test_sessions_mtls.py +++ b/packages/google-auth/tests/transport/aio/test_sessions_mtls.py @@ -183,7 +183,7 @@ async def test_configure_mtls_channel_custom_request(self): await session.close() @pytest.mark.asyncio - async def test_configure_mtls_channel_exception_resets_flag(self): + async def test_configure_mtls_channel_exception_preserves_flag(self): mock_creds = mock.AsyncMock(spec=credentials.Credentials) session = sessions.AsyncAuthorizedSession(mock_creds) session._is_mtls = True @@ -199,9 +199,9 @@ async def test_configure_mtls_channel_exception_resets_flag(self): ), pytest.raises(exceptions.MutualTLSChannelError), ): - await session.configure_mtls_channel() - assert session.is_mtls is False - assert session._cached_cert == None + await session.configure_mtls_channel(lambda: (b"new_cert", b"new_key")) + assert session.is_mtls is True + assert session._cached_cert == b"old_cert" await session.close() @pytest.mark.asyncio @@ -1471,3 +1471,82 @@ async def test_401_refresh_raises_invalid_operation_returns_401(self): assert resp == mock_resp_401 mock_creds.refresh.assert_called_once() await session.close() + + @pytest.mark.asyncio + async def test_configure_mtls_channel_idempotent_when_called_repeatedly(self): + """Tests that calling configure_mtls_channel repeatedly without a new callback reuses the existing task.""" + with ( + mock.patch( + "google.auth.transport._mtls_helper.check_use_client_cert", + return_value=True, + ), + mock.patch( + "google.auth.aio.transport.mtls.get_client_cert_and_key", + return_value=(True, b"cert_bytes", b"key_bytes"), + ), + mock.patch( + "google.auth.aio.transport.mtls.make_client_cert_ssl_context", + return_value=mock.Mock(spec=ssl.SSLContext), + ), + mock.patch("aiohttp.TCPConnector"), + mock.patch("aiohttp.ClientSession"), + ): + mock_creds = mock.AsyncMock(spec=credentials.Credentials) + session = sessions.AsyncAuthorizedSession(mock_creds) + await session.configure_mtls_channel() + assert session.is_mtls is True + first_auth_req = session._auth_request + first_task = session._mtls_init_task + + # Call configure_mtls_channel again without new callback + await session.configure_mtls_channel() + assert session._auth_request is first_auth_req + assert session._mtls_init_task is first_task + + # Call configure_mtls_channel with a new callback - should reconfigure + new_callback = lambda: (b"new_cert", b"new_key") + await session.configure_mtls_channel(new_callback) + assert session._auth_request is not first_auth_req + await session.close() + + @pytest.mark.asyncio + async def test_configure_mtls_channel_exception_preserves_existing_mtls_state(self): + """Tests that an exception during re-configuration does not clear existing is_mtls and cached_cert.""" + with ( + mock.patch( + "google.auth.transport._mtls_helper.check_use_client_cert", + return_value=True, + ), + mock.patch( + "google.auth.aio.transport.mtls.get_client_cert_and_key", + return_value=(True, b"cert_v1", b"key_v1"), + ), + mock.patch( + "google.auth.aio.transport.mtls.make_client_cert_ssl_context", + return_value=mock.Mock(spec=ssl.SSLContext), + ), + mock.patch("aiohttp.TCPConnector"), + mock.patch("aiohttp.ClientSession"), + ): + mock_creds = mock.AsyncMock(spec=credentials.Credentials) + session = sessions.AsyncAuthorizedSession(mock_creds) + await session.configure_mtls_channel() + assert session.is_mtls is True + assert session._cached_cert == b"cert_v1" + first_auth_req = session._auth_request + + # Now attempt reconfiguring with a failing callback/context + with ( + mock.patch( + "google.auth.aio.transport.mtls.get_client_cert_and_key", + side_effect=RuntimeError("Reconfig failure"), + ), + pytest.raises(exceptions.MutualTLSChannelError), + ): + await session.configure_mtls_channel(lambda: (b"cert_v2", b"key_v2")) + + # Session should still retain its previous mTLS state and active auth_request + assert session.is_mtls is True + assert session._cached_cert == b"cert_v1" + assert session._auth_request is first_auth_req + await session.close() From 5aef5c3ca9b65c33135b71b90aa53190cb8ba98b Mon Sep 17 00:00:00 2001 From: Radhika Agrawal Date: Thu, 10 Sep 2026 19:54:29 +0000 Subject: [PATCH 06/11] test(auth): make test_401_retry_raises_timeout_before_subsequent_retry robust across Python versions --- .../tests/transport/aio/test_sessions_mtls.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 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 d8e12494b73c..9afbdbca7e1d 100644 --- a/packages/google-auth/tests/transport/aio/test_sessions_mtls.py +++ b/packages/google-auth/tests/transport/aio/test_sessions_mtls.py @@ -1101,12 +1101,15 @@ async def fake_refresh(auth_request): mock_creds, auth_request=mock_auth_request ) - time_calls = [0.0, 0.0, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 100.0] + after_refresh_count = 0 def mock_time(): - if time_calls: - return time_calls.pop(0) - return 100.0 + nonlocal after_refresh_count + if mock_creds.refresh.called: + after_refresh_count += 1 + if after_refresh_count >= 2: + return 100.0 + return 0.1 with mock.patch("time.monotonic", side_effect=mock_time): with pytest.raises( From 96ec6228c2b244b9878970f2739adc55e3d767da Mon Sep 17 00:00:00 2001 From: Radhika Agrawal Date: Thu, 10 Sep 2026 20:06:03 +0000 Subject: [PATCH 07/11] fix(auth): allow mTLS retry when credentials raise NotImplementedError on refresh - In AsyncAuthorizedSession.request()'s _recover_auth_state(), do not return 401 response on NotImplementedError so that mTLS reconfiguration can fall through to retry - Add unit test test_cert_rotation_credential_refresh_not_implemented_retries --- .../google/auth/aio/transport/sessions.py | 1 - .../tests/transport/aio/test_sessions_mtls.py | 52 +++++++++++++++++++ 2 files changed, 52 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 86368bdfc34c..c914cdaefb6e 100644 --- a/packages/google-auth/google/auth/aio/transport/sessions.py +++ b/packages/google-auth/google/auth/aio/transport/sessions.py @@ -496,7 +496,6 @@ async def _recover_auth_state(): _LOGGER.debug( "Credentials do not implement refresh()." ) - return response except ( exceptions.RefreshError, exceptions.InvalidOperation, 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 9afbdbca7e1d..911cf464ec95 100644 --- a/packages/google-auth/tests/transport/aio/test_sessions_mtls.py +++ b/packages/google-auth/tests/transport/aio/test_sessions_mtls.py @@ -1553,3 +1553,55 @@ async def test_configure_mtls_channel_exception_preserves_existing_mtls_state(se assert session._cached_cert == b"cert_v1" assert session._auth_request is first_auth_req 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: + 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", + ) + + 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 688f76fe3b1a837e278ca8e97f577f12863b8ceb Mon Sep 17 00:00:00 2001 From: Radhika Agrawal Date: Thu, 10 Sep 2026 20:25:11 +0000 Subject: [PATCH 08/11] style(auth): format with black and fix flake8 style issues --- .../google/auth/aio/transport/sessions.py | 13 ++++++++----- .../tests/transport/aio/test_sessions_mtls.py | 16 +++++++--------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/packages/google-auth/google/auth/aio/transport/sessions.py b/packages/google-auth/google/auth/aio/transport/sessions.py index c914cdaefb6e..8333ae3b0f0c 100644 --- a/packages/google-auth/google/auth/aio/transport/sessions.py +++ b/packages/google-auth/google/auth/aio/transport/sessions.py @@ -450,10 +450,10 @@ async def _recover_auth_state(): ) await self.configure_mtls_channel( lambda: ( - call_cert_bytes, - call_key_bytes, - ) - ) + call_cert_bytes, + call_key_bytes, + ) + ) channel_reconfigured = True except Exception as e: _LOGGER.error( @@ -485,7 +485,10 @@ async def _recover_auth_state(): async with self._refresh_lock: # Check if another task already refreshed credentials while we were waiting - if not channel_reconfigured and self._refresh_counter > refresh_counter_at_error: + if ( + not channel_reconfigured + and self._refresh_counter > refresh_counter_at_error + ): _LOGGER.debug( "Credentials were already refreshed by a concurrent task. Skipping duplicate refresh." ) 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 911cf464ec95..77f6eca6e0e3 100644 --- a/packages/google-auth/tests/transport/aio/test_sessions_mtls.py +++ b/packages/google-auth/tests/transport/aio/test_sessions_mtls.py @@ -179,7 +179,7 @@ async def test_configure_mtls_channel_custom_request(self): ): await session.configure_mtls_channel() assert session.is_mtls is False - assert session._cached_cert == None + assert session._cached_cert is None await session.close() @pytest.mark.asyncio @@ -226,7 +226,7 @@ async def test_configure_mtls_channel_transport_error_resets_flag(self): ): await session.configure_mtls_channel() assert session.is_mtls is False - assert session._cached_cert == None + assert session._cached_cert is None await session.close() @pytest.mark.asyncio @@ -1425,9 +1425,7 @@ async def test_401_cert_check_without_cached_cert_skips_reconfiguration(self): new_callable=mock.AsyncMock, return_value=(True, b"cert_bytes", b"key_bytes"), ), - mock.patch( - "google.auth._agent_identity_utils.parse_certificate" - ), + mock.patch("google.auth._agent_identity_utils.parse_certificate"), mock.patch( "google.auth._agent_identity_utils.calculate_certificate_fingerprint", return_value="FINGERPRINT_1", @@ -1468,9 +1466,7 @@ async def test_401_refresh_raises_invalid_operation_returns_401(self): mock_creds, auth_request=mock_auth_req ) - resp = await session.request( - "GET", "https://example.com" - ) + resp = await session.request("GET", "https://example.com") assert resp == mock_resp_401 mock_creds.refresh.assert_called_once() await session.close() @@ -1507,7 +1503,9 @@ async def test_configure_mtls_channel_idempotent_when_called_repeatedly(self): assert session._mtls_init_task is first_task # Call configure_mtls_channel with a new callback - should reconfigure - new_callback = lambda: (b"new_cert", b"new_key") + def new_callback(): + return b"new_cert", b"new_key" + await session.configure_mtls_channel(new_callback) assert session._auth_request is not first_auth_req await session.close() From 54eb0bc0c0d39e4c3cb22f24b52936d1d93c16e0 Mon Sep 17 00:00:00 2001 From: Radhika Agrawal Date: Thu, 10 Sep 2026 20:30:57 +0000 Subject: [PATCH 09/11] test(auth): add unit test for consecutive mTLS certificate rotations --- .../tests/transport/aio/test_sessions_mtls.py | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) 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 77f6eca6e0e3..bff8e6c1420f 100644 --- a/packages/google-auth/tests/transport/aio/test_sessions_mtls.py +++ b/packages/google-auth/tests/transport/aio/test_sessions_mtls.py @@ -1603,3 +1603,61 @@ 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_401_mtls_consecutive_multi_rotation(self): + """Verifies that multiple consecutive rotations (v1 -> v2 -> v3) succeed.""" + mock_creds = mock.AsyncMock(spec=credentials.Credentials) + mock_creds.before_request = mock.AsyncMock(return_value=None) + mock_creds.refresh = mock.AsyncMock(return_value=None) + + session = sessions.AsyncAuthorizedSession(mock_creds) + session._is_mtls = True + session._cached_cert = b"cert_v1" + + # Rotation 1: v1 -> v2 + with mock.patch( + "google.auth.aio.transport.mtls.check_parameters_for_unauthorized_response", + new_callable=mock.AsyncMock, + return_value=(b"cert_v2", b"key_v2", b"fp1", b"fp2"), + ): + with mock.patch.object( + session, "configure_mtls_channel", new_callable=mock.AsyncMock + ) as mock_conf: + mock_auth = mock.AsyncMock( + side_effect=[ + mock.Mock(status_code=401, close=mock.AsyncMock()), + mock.Mock(status_code=200, close=mock.AsyncMock()), + ] + ) + session._auth_request = mock_auth + await session.request("GET", "https://pubsub.mtls.googleapis.com/test") + mock_conf.assert_called_once() + assert session._client_cert_callback is None + + session._cached_cert = b"cert_v2" + + # Rotation 2: v2 -> v3 (Must still have client_cert_callback == None to read disk) + with mock.patch( + "google.auth.aio.transport.mtls.check_parameters_for_unauthorized_response", + new_callable=mock.AsyncMock, + return_value=(b"cert_v3", b"key_v3", b"fp2", b"fp3"), + ) as mock_check: + with mock.patch.object( + session, "configure_mtls_channel", new_callable=mock.AsyncMock + ) as mock_conf: + mock_auth = mock.AsyncMock( + side_effect=[ + mock.Mock(status_code=401, close=mock.AsyncMock()), + mock.Mock(status_code=200, close=mock.AsyncMock()), + ] + ) + session._auth_request = mock_auth + await session.request("GET", "https://pubsub.mtls.googleapis.com/test") + # Verify check was called with callback=None (allowing disk read) + mock_check.assert_called_with(b"cert_v2", None) + mock_conf.assert_called_once() + assert session._client_cert_callback is None + + await session.close() + From 118785bd9a84623bb0078b96a4f48b45a057d396 Mon Sep 17 00:00:00 2001 From: Radhika Agrawal Date: Thu, 10 Sep 2026 20:38:06 +0000 Subject: [PATCH 10/11] test(auth): pass mock_auth_request in cancellation test to avoid unmocked transport --- .../google-auth/tests/transport/aio/test_sessions_mtls.py | 5 ++++- 1 file changed, 4 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 bff8e6c1420f..5b5d097deb17 100644 --- a/packages/google-auth/tests/transport/aio/test_sessions_mtls.py +++ b/packages/google-auth/tests/transport/aio/test_sessions_mtls.py @@ -1245,7 +1245,10 @@ async def test_request_cancellation_propagates_and_leaves_mtls_init_running(self in the background. """ mock_creds = mock.AsyncMock(spec=credentials.Credentials) - session = sessions.AsyncAuthorizedSession(mock_creds) + mock_auth_request = mock.AsyncMock(spec=transport.Request) + session = sessions.AsyncAuthorizedSession( + mock_creds, auth_request=mock_auth_request + ) init_started = asyncio.Event() init_can_finish = asyncio.Event() From e0467602e9030674b7064365cd2acbdd3e9d73ef Mon Sep 17 00:00:00 2001 From: Radhika Agrawal Date: Thu, 10 Sep 2026 20:56:32 +0000 Subject: [PATCH 11/11] test(auth): allow timeout_guard error message in test_401_retry_raises_timeout_before_subsequent_retry --- packages/google-auth/tests/transport/aio/test_sessions_mtls.py | 3 +-- 1 file changed, 1 insertion(+), 2 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 5b5d097deb17..a89a261ae9b3 100644 --- a/packages/google-auth/tests/transport/aio/test_sessions_mtls.py +++ b/packages/google-auth/tests/transport/aio/test_sessions_mtls.py @@ -1114,7 +1114,7 @@ def mock_time(): with mock.patch("time.monotonic", side_effect=mock_time): with pytest.raises( exceptions.TimeoutError, - match="Timeout exceeded before retrying the request", + match=r"(Timeout exceeded before retrying the request|Context manager exceeded the configured timeout)", ): await session.request( "GET", "https://example.com", max_allowed_time=1.0 @@ -1663,4 +1663,3 @@ async def test_401_mtls_consecutive_multi_rotation(self): assert session._client_cert_callback is None await session.close() -