Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 49 additions & 6 deletions google/ads/googleads/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,46 @@
"""Errors used by the Google Ads API library."""

import importlib
import inspect
import re
from typing import Any, Optional

import grpc
import requests
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.<version>`` 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."""
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
114 changes: 114 additions & 0 deletions tests/errors_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,127 @@ 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"
) as mock_raise:
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__,
)
Comment on lines +154 to +181

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test Performance Optimization & Edge-Case Coverage:

  1. Avoid loading the entire v25.services package tree in unit tests: Importing google.ads.googleads.v25.services.services.you_tube_video_upload_service.transports.resumable_upload executes google/ads/googleads/v25/__init__.py and google/ads/googleads/v25/services/__init__.py (1,502 lines), which eagerly imports every service, resource, enum, and protobuf descriptor in v25 on Python < 3.15. In addition, calling upload._process_initiate_response couples this test to a private method on a generated transport class.
    You can test stack-frame version inference in <1ms without importing v25.services by executing a small helper with a versioned __name__ in its globals (e.g., exec("def call(r): errors.raise_formatted_for_status(r)", {"__name__": f"google.ads.googleads.{older_version}.services.test", "errors": errors})), or by loading resumable_upload.py without triggering v25/services/__init__.py.
  2. Additional edge cases: Consider adding a unit test verifying that _get_caller_version() falls back to self.version when inspect.currentframe() returns None (with mock.patch("inspect.currentframe", return_value=None): ...), and a case verifying raise_formatted_ads_exception(self.response) directly infers a non-default caller version.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.


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(
Expand Down
Loading