From d36c9ddc45befcb86a4797b41be747719642040f Mon Sep 17 00:00:00 2001 From: JackyHsu-X Date: Tue, 27 Oct 2026 11:40:53 +0300 Subject: [PATCH 1/2] fix(google-api-core): report background polling failures Assisted-by: OpenAI Codex --- .../google/api_core/future/async_future.py | 13 +++- .../tests/asyncio/future/test_async_future.py | 74 ++++++++++++++++++- .../tests/asyncio/test_operation_async.py | 24 +++++- 3 files changed, 107 insertions(+), 4 deletions(-) diff --git a/packages/google-api-core/google/api_core/future/async_future.py b/packages/google-api-core/google/api_core/future/async_future.py index 57fdd083a1c0..59a902106c67 100644 --- a/packages/google-api-core/google/api_core/future/async_future.py +++ b/packages/google-api-core/google/api_core/future/async_future.py @@ -134,6 +134,14 @@ async def exception(self, timeout=None): await self._blocking_poll(timeout=timeout) return self._future.exception() + async def _background_poll(self): + """Poll in the background and report failures through the future.""" + try: + await self._blocking_poll() + except Exception as exc: + if not self._future.done(): + self.set_exception(exc) + def add_done_callback(self, fn): """Add a callback to be executed when the operation is complete. @@ -141,13 +149,16 @@ def add_done_callback(self, fn): event loop. Otherwise, the callback will be stored and invoked when the future is done. + A background polling failure is stored as the future's exception and + also causes the callbacks to be invoked. + Args: fn (Callable[Future]): The callback to execute when the operation is complete. """ if self._background_task is None: self._background_task = asyncio.get_event_loop().create_task( - self._blocking_poll() + self._background_poll() ) self._future.add_done_callback(fn) diff --git a/packages/google-api-core/tests/asyncio/future/test_async_future.py b/packages/google-api-core/tests/asyncio/future/test_async_future.py index 659f41cf90ec..0658c1293c39 100644 --- a/packages/google-api-core/tests/asyncio/future/test_async_future.py +++ b/packages/google-api-core/tests/asyncio/future/test_async_future.py @@ -16,7 +16,6 @@ from unittest import mock import pytest - from google.api_core import exceptions from google.api_core.future import async_future @@ -225,3 +224,76 @@ def callback2(unused_future): assert future.poll_count == 1 await callback_called.wait() await callback_called2.wait() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("error_type", [exceptions.Forbidden, ValueError]) +async def test_callback_polling_error(error_type): + future = AsyncFuture() + error = error_type("polling failed") + future.done = mock.AsyncMock(side_effect=error) + callback = mock.Mock() + + future.add_done_callback(callback) + await asyncio.gather(future._background_task, return_exceptions=True) + await asyncio.sleep(0) + + callback.assert_called_once_with(future._future) + assert await future.exception() is error + with pytest.raises(error_type) as exc_info: + await future.result() + assert exc_info.value is error + future.done.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_background_polling_error_after_result(): + future = AsyncFuture() + polling_started = asyncio.Event() + finish_polling = asyncio.Event() + + async def done(): + polling_started.set() + await finish_polling.wait() + raise ValueError("polling failed after completion") + + future.done = done + callback = mock.Mock() + future.add_done_callback(callback) + await polling_started.wait() + future.set_result(42) + finish_polling.set() + outcomes = await asyncio.gather(future._background_task, return_exceptions=True) + await asyncio.sleep(0) + + assert outcomes == [None] + callback.assert_called_once_with(future._future) + assert await future.result() == 42 + + +@pytest.mark.asyncio +async def test_background_polling_cancelled(): + future = AsyncFutureWithPoll() + callback = mock.Mock() + future.add_done_callback(callback) + await asyncio.sleep(0) + future._background_task.cancel() + + with pytest.raises(asyncio.CancelledError): + await future._background_task + + callback.assert_not_called() + assert not future._future.done() + + +@pytest.mark.asyncio +async def test_foreground_polling_error(): + future = AsyncFuture() + error = exceptions.Forbidden("polling failed") + future.done = mock.AsyncMock(side_effect=error) + + with pytest.raises(exceptions.Forbidden) as exc_info: + await future.result() + + assert exc_info.value is error + assert not future._future.done() diff --git a/packages/google-api-core/tests/asyncio/test_operation_async.py b/packages/google-api-core/tests/asyncio/test_operation_async.py index 601c8995d4f6..8cb48ca69bad 100644 --- a/packages/google-api-core/tests/asyncio/test_operation_async.py +++ b/packages/google-api-core/tests/asyncio/test_operation_async.py @@ -13,6 +13,8 @@ # limitations under the License. +import asyncio + import pytest try: @@ -26,12 +28,11 @@ except ImportError: # pragma: NO COVER pytest.skip("No GRPC", allow_module_level=True) +from google.api_core import exceptions, operation_async, operations_v1, retry_async from google.longrunning import operations_pb2 from google.protobuf import struct_pb2 from google.rpc import code_pb2, status_pb2 -from google.api_core import exceptions, operation_async, operations_v1, retry_async - TEST_OPERATION_NAME = "test/operation" @@ -81,6 +82,25 @@ async def test_constructor(): assert await future.running() +@pytest.mark.asyncio +async def test_callback_refresh_error(): + future, refresh, _ = make_operation_future() + error = exceptions.Forbidden("polling permission denied") + refresh.side_effect = error + callback = mock.Mock() + + future.add_done_callback(callback) + await asyncio.gather(future._background_task, return_exceptions=True) + await asyncio.sleep(0) + + callback.assert_called_once_with(future._future) + assert await future.exception() is error + with pytest.raises(exceptions.Forbidden) as exc_info: + await future.result() + assert exc_info.value is error + refresh.assert_awaited_once() + + @pytest.mark.asyncio async def test_metadata(): expected_metadata = struct_pb2.Struct() From 6bcb062f3c8bf45c2ac567a09116365129331f18 Mon Sep 17 00:00:00 2001 From: JackyHsu-X Date: Wed, 4 Nov 2026 00:01:42 +0300 Subject: [PATCH 2/2] fix: keep async test import groups consistent --- .../google-api-core/tests/asyncio/future/test_async_future.py | 2 ++ .../google-api-core/tests/asyncio/test_operation_async.py | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/google-api-core/tests/asyncio/future/test_async_future.py b/packages/google-api-core/tests/asyncio/future/test_async_future.py index 0658c1293c39..8e9f2ad90e5c 100644 --- a/packages/google-api-core/tests/asyncio/future/test_async_future.py +++ b/packages/google-api-core/tests/asyncio/future/test_async_future.py @@ -16,6 +16,8 @@ from unittest import mock import pytest + +# isort: split from google.api_core import exceptions from google.api_core.future import async_future diff --git a/packages/google-api-core/tests/asyncio/test_operation_async.py b/packages/google-api-core/tests/asyncio/test_operation_async.py index 8cb48ca69bad..71a33e5befea 100644 --- a/packages/google-api-core/tests/asyncio/test_operation_async.py +++ b/packages/google-api-core/tests/asyncio/test_operation_async.py @@ -28,11 +28,13 @@ except ImportError: # pragma: NO COVER pytest.skip("No GRPC", allow_module_level=True) -from google.api_core import exceptions, operation_async, operations_v1, retry_async from google.longrunning import operations_pb2 from google.protobuf import struct_pb2 from google.rpc import code_pb2, status_pb2 +# isort: split +from google.api_core import exceptions, operation_async, operations_v1, retry_async + TEST_OPERATION_NAME = "test/operation"