diff --git a/google/ads/googleads/errors.py b/google/ads/googleads/errors.py index a720b4efb..f541310e8 100644 --- a/google/ads/googleads/errors.py +++ b/google/ads/googleads/errors.py @@ -14,6 +14,8 @@ """Errors used by the Google Ads API library.""" import importlib +import inspect +import re from typing import Any, Optional import grpc @@ -21,6 +23,37 @@ from google.api_core import exceptions as core_exceptions from proto import Message as ProtobufMessageType +_VERSION_PREFIX = "google.ads.googleads.v" +_VERSION_REGEX = re.compile(r"^google\.ads\.googleads\.(v\d+(?:_\d+)?)(?:\.|$)") + + +def _get_caller_version() -> str: + """Determines the Google Ads API version from the call stack. + + Walks the call stack looking for a frame originating from a versioned + ``google.ads.googleads.`` module. Falls back to + ``_DEFAULT_VERSION`` if no versioned module is found. + + Returns: + The detected API version string (e.g., "v25") or ``_DEFAULT_VERSION``. + """ + frame = inspect.currentframe() + try: + if frame is not None: + frame = frame.f_back + while frame is not None: + module_name = frame.f_globals.get("__name__", "") + if module_name.startswith(_VERSION_PREFIX): + match = _VERSION_REGEX.match(module_name) + if match: + return match.group(1) + frame = frame.f_back + finally: + del frame + from google.ads.googleads.client import _DEFAULT_VERSION + + return _DEFAULT_VERSION + class GoogleAdsException(Exception): """Exception thrown in response to an API error from GoogleAds servers.""" @@ -100,20 +133,27 @@ def add_callback(self, callback: Any) -> None: pass -def raise_formatted_ads_exception(response: requests.Response, version: str): +def raise_formatted_ads_exception( + response: requests.Response, version: Optional[str] = None +): """Converts a RUP HTTP response into a rich GoogleAdsException. Args: response (requests.Response): The HTTP response containing error details. - version (str): - The API version (e.g., "v23") used to import error classes. + version (Optional[str]): + The API version (e.g., "v23") used to import error classes. If not + provided, it is inferred from the calling service module or falls + back to the default API version. Raises: GoogleAdsException: A rich exception containing the mapped gRPC status, failure details, and request ID. """ + if not version: + version = _get_caller_version() + # 1. Map HTTP status (e.g., 400) to gRPC status (e.g., INVALID_ARGUMENT) core_error = core_exceptions.from_http_response(response) grpc_code = core_error.grpc_status_code or grpc.StatusCode.INTERNAL @@ -137,14 +177,17 @@ def raise_formatted_ads_exception(response: requests.Response, version: str): ) -def raise_formatted_for_status(response: requests.Response, version: str): +def raise_formatted_for_status( + response: requests.Response, version: Optional[str] = None +): """Replacement for response.raise_for_status() that raises GoogleAdsException. Args: response (requests.Response): The HTTP response to check for errors. - version (str): - The API version (e.g., "v23"). + version (Optional[str]): + The API version (e.g., "v23"). If not provided, it is inferred from + the calling service module or falls back to the default API version. Raises: GoogleAdsException: diff --git a/tests/errors_test.py b/tests/errors_test.py index efd1c0a2f..63b52089c 100644 --- a/tests/errors_test.py +++ b/tests/errors_test.py @@ -78,6 +78,65 @@ def test_raise_formatted_ads_exception(self): e_code = exception.failure.errors[0].error_code self.assertIsNotNone(e_code.request_error) + def test_raise_formatted_ads_exception_default_version(self): + with mock.patch( + "google.api_core.exceptions.from_http_response" + ) as mock_from_http: + mock_core_error = mock.Mock() + mock_core_error.grpc_status_code = grpc.StatusCode.NOT_FOUND + mock_core_error.message = "Core exception message" + mock_from_http.return_value = mock_core_error + + with self.assertRaises(errors.GoogleAdsException) as cm: + errors.raise_formatted_ads_exception(self.response) + + exception = cm.exception + self.assertEqual(exception.error.version, self.version) + + def test_raise_formatted_ads_exception_empty_version(self): + with mock.patch( + "google.api_core.exceptions.from_http_response" + ) as mock_from_http: + mock_core_error = mock.Mock() + mock_core_error.grpc_status_code = grpc.StatusCode.NOT_FOUND + mock_core_error.message = "Core exception message" + mock_from_http.return_value = mock_core_error + + with self.assertRaises(errors.GoogleAdsException) as cm: + errors.raise_formatted_ads_exception(self.response, "") + + exception = cm.exception + self.assertEqual(exception.error.version, self.version) + + def test_raise_formatted_ads_exception_infers_caller_version(self): + older_version = Client._VALID_API_VERSIONS[1] + caller_globals = { + "__name__": f"google.ads.googleads.{older_version}.services.test", + "errors": errors, + } + exec( + "def call(r): errors.raise_formatted_ads_exception(r)", + caller_globals, + ) + + with mock.patch( + "google.api_core.exceptions.from_http_response" + ) as mock_from_http: + mock_core_error = mock.Mock() + mock_core_error.grpc_status_code = grpc.StatusCode.INVALID_ARGUMENT + mock_core_error.message = "Error from older version service" + mock_from_http.return_value = mock_core_error + + with self.assertRaises(errors.GoogleAdsException) as cm: + caller_globals["call"](self.response) + + exception = cm.exception + self.assertEqual(exception.error.version, older_version) + self.assertIn( + f"google.ads.googleads.{older_version}.errors", + type(exception.failure).__module__, + ) + def test_raise_formatted_for_status_when_not_ok(self): with mock.patch( "google.ads.googleads.errors.raise_formatted_ads_exception" @@ -85,6 +144,61 @@ def test_raise_formatted_for_status_when_not_ok(self): errors.raise_formatted_for_status(self.response, self.version) mock_raise.assert_called_once_with(self.response, self.version) + def test_raise_formatted_for_status_default_version(self): + with mock.patch( + "google.ads.googleads.errors.raise_formatted_ads_exception" + ) as mock_raise: + errors.raise_formatted_for_status(self.response) + mock_raise.assert_called_once_with(self.response, None) + + def test_raise_formatted_for_status_infers_caller_version(self): + older_version = Client._VALID_API_VERSIONS[1] + caller_globals = { + "__name__": f"google.ads.googleads.{older_version}.services.test", + "errors": errors, + } + exec( + "def call(r): errors.raise_formatted_for_status(r)", + caller_globals, + ) + + with mock.patch( + "google.api_core.exceptions.from_http_response" + ) as mock_from_http: + mock_core_error = mock.Mock() + mock_core_error.grpc_status_code = grpc.StatusCode.INVALID_ARGUMENT + mock_core_error.message = "Error from older version service" + mock_from_http.return_value = mock_core_error + + with self.assertRaises(errors.GoogleAdsException) as cm: + caller_globals["call"](self.response) + + exception = cm.exception + self.assertEqual(exception.error.version, older_version) + self.assertIn( + f"google.ads.googleads.{older_version}.errors", + type(exception.failure).__module__, + ) + + def test_get_caller_version_when_currentframe_is_none(self): + with mock.patch("inspect.currentframe", return_value=None): + self.assertEqual(errors._get_caller_version(), self.version) + + def test_get_caller_version_point_release_and_root_module(self): + for module_name, expected_version in [ + ("google.ads.googleads.v26_1.services.test", "v26_1"), + ("google.ads.googleads.v25", "v25"), + ]: + caller_globals = { + "__name__": module_name, + "errors": errors, + } + exec( + "def call(): return errors._get_caller_version()", + caller_globals, + ) + self.assertEqual(caller_globals["call"](), expected_version) + def test_raise_formatted_for_status_when_ok(self): self.response.ok = True with mock.patch(