From 79f3668453554e7db059578a4276097080a78259 Mon Sep 17 00:00:00 2001 From: Heba Alazzeh Date: Thu, 16 Jul 2026 16:17:52 +0000 Subject: [PATCH 1/3] feat(api-core): move request-id auto-population logic to gapic_v1 public helpers Introduces method_helpers module containing setup_request_id helper. Exposes the module as public in gapic_v1. --- .../google/api_core/gapic_v1/__init__.py | 4 +- .../api_core/gapic_v1/method_helpers.py | 59 +++++++++ .../tests/unit/gapic/test_method_helpers.py | 118 ++++++++++++++++++ 3 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 packages/google-api-core/google/api_core/gapic_v1/method_helpers.py create mode 100644 packages/google-api-core/tests/unit/gapic/test_method_helpers.py diff --git a/packages/google-api-core/google/api_core/gapic_v1/__init__.py b/packages/google-api-core/google/api_core/gapic_v1/__init__.py index 48a27ec21d24..b112d6fc5cc0 100644 --- a/packages/google-api-core/google/api_core/gapic_v1/__init__.py +++ b/packages/google-api-core/google/api_core/gapic_v1/__init__.py @@ -25,9 +25,10 @@ # Older Python versions safely ignore this variable. __lazy_modules__: Set[str] = { "google.api_core.gapic_v1.client_info", + "google.api_core.gapic_v1.method_helpers", "google.api_core.gapic_v1.routing_header", } -__all__ = ["client_info", "routing_header"] +__all__ = ["client_info", "method_helpers", "routing_header"] if _has_grpc: __lazy_modules__.update( @@ -41,6 +42,7 @@ from google.api_core.gapic_v1 import ( # noqa: E402 client_info, + method_helpers, routing_header, ) diff --git a/packages/google-api-core/google/api_core/gapic_v1/method_helpers.py b/packages/google-api-core/google/api_core/gapic_v1/method_helpers.py new file mode 100644 index 000000000000..a79d23bf6b19 --- /dev/null +++ b/packages/google-api-core/google/api_core/gapic_v1/method_helpers.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +"""Helpers for method requests.""" + +import uuid +from typing import Union +import google.protobuf.message + + +def setup_request_id( + request: Union[google.protobuf.message.Message, dict, None], + field_name: str, + is_proto3_optional: bool, +) -> None: + """Populate a UUID4 field in the request if it is not already set. + + Args: + request (Union[google.protobuf.message.Message, dict]): The + request object. + field_name (str): The name of the field to populate. + is_proto3_optional (bool): Whether the field is proto3 optional. + """ + if request is None: + return + + if isinstance(request, dict): + if is_proto3_optional: + if field_name not in request: + request[field_name] = str(uuid.uuid4()) + elif not request.get(field_name): + request[field_name] = str(uuid.uuid4()) + return + + if is_proto3_optional: + try: + # Pure protobuf messages + if not request.HasField(field_name): + setattr(request, field_name, str(uuid.uuid4())) + except (AttributeError, ValueError): + # Proto-plus messages or other objects + if not getattr(request, field_name, None): + setattr(request, field_name, str(uuid.uuid4())) + else: + if not getattr(request, field_name, None): + setattr(request, field_name, str(uuid.uuid4())) diff --git a/packages/google-api-core/tests/unit/gapic/test_method_helpers.py b/packages/google-api-core/tests/unit/gapic/test_method_helpers.py new file mode 100644 index 000000000000..61189a72a965 --- /dev/null +++ b/packages/google-api-core/tests/unit/gapic/test_method_helpers.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import re +from google.api_core.gapic_v1.method_helpers import setup_request_id + + +def test_setup_request_id(): + class MockRequest: + def __init__(self, **kwargs): + for k, v in kwargs.items(): + setattr(self, k, v) + + def __contains__(self, key): + return hasattr(self, key) + + class MockProtoRequest: + def __init__(self, **kwargs): + for k, v in kwargs.items(): + setattr(self, k, v) + + def HasField(self, key): + return hasattr(self, key) + + # Test with proto3 optional field not in request + request = MockRequest() + setup_request_id(request, "request_id", True) + assert re.match( + r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", + request.request_id, + ) + + # Test with proto3 optional field already in request + request = MockRequest(request_id="already_set") + setup_request_id(request, "request_id", True) + assert request.request_id == "already_set" + + # Test with non-proto3 optional field empty + request = MockRequest(request_id="") + setup_request_id(request, "request_id", False) + assert re.match( + r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", + request.request_id, + ) + + # Test with non-proto3 optional field already set + request = MockRequest(request_id="already_set") + setup_request_id(request, "request_id", False) + assert request.request_id == "already_set" + + # Test with proto3 optional field not in request (MockProtoRequest) + request = MockProtoRequest() + setup_request_id(request, "request_id", True) + assert re.match( + r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", + request.request_id, + ) + + # Test with proto3 optional field already in request (MockProtoRequest) + request = MockProtoRequest(request_id="already_set") + setup_request_id(request, "request_id", True) + assert request.request_id == "already_set" + + # Test with ValueError + class MockValueErrorRequest: + def HasField(self, key): + raise ValueError("Mismatched field") + + def __contains__(self, key): + return hasattr(self, key) + + request = MockValueErrorRequest() + setup_request_id(request, "request_id", True) + assert re.match( + r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", + request.request_id, + ) + + # Test with dict and proto3 optional field not in request + request = {} + setup_request_id(request, "request_id", True) + assert re.match( + r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", + request["request_id"], + ) + + # Test with dict and proto3 optional field already in request + request = {"request_id": "already_set"} + setup_request_id(request, "request_id", True) + assert request["request_id"] == "already_set" + + # Test with dict and non-proto3 optional field empty + request = {"request_id": ""} + setup_request_id(request, "request_id", False) + assert re.match( + r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", + request["request_id"], + ) + + # Test with dict and non-proto3 optional field already set + request = {"request_id": "already_set"} + setup_request_id(request, "request_id", False) + assert request["request_id"] == "already_set" + + # Test with None request (should handle gracefully without raising exception) + setup_request_id(None, "request_id", True) From 62fbcc87261077bb2dd6e04508b0a694c2f62f56 Mon Sep 17 00:00:00 2001 From: Heba Alazzeh Date: Thu, 16 Jul 2026 18:14:03 +0000 Subject: [PATCH 2/3] refactor(api-core): rename gapic_v1.method_helpers to gapic_v1.request and improve docstring --- .../google-api-core/google/api_core/gapic_v1/__init__.py | 6 +++--- .../api_core/gapic_v1/{method_helpers.py => request.py} | 8 +++++++- .../gapic/{test_method_helpers.py => test_request.py} | 3 ++- 3 files changed, 12 insertions(+), 5 deletions(-) rename packages/google-api-core/google/api_core/gapic_v1/{method_helpers.py => request.py} (89%) rename packages/google-api-core/tests/unit/gapic/{test_method_helpers.py => test_request.py} (98%) diff --git a/packages/google-api-core/google/api_core/gapic_v1/__init__.py b/packages/google-api-core/google/api_core/gapic_v1/__init__.py index b112d6fc5cc0..010ff6ed3ef7 100644 --- a/packages/google-api-core/google/api_core/gapic_v1/__init__.py +++ b/packages/google-api-core/google/api_core/gapic_v1/__init__.py @@ -25,10 +25,10 @@ # Older Python versions safely ignore this variable. __lazy_modules__: Set[str] = { "google.api_core.gapic_v1.client_info", - "google.api_core.gapic_v1.method_helpers", + "google.api_core.gapic_v1.request", "google.api_core.gapic_v1.routing_header", } -__all__ = ["client_info", "method_helpers", "routing_header"] +__all__ = ["client_info", "request", "routing_header"] if _has_grpc: __lazy_modules__.update( @@ -42,7 +42,7 @@ from google.api_core.gapic_v1 import ( # noqa: E402 client_info, - method_helpers, + request, routing_header, ) diff --git a/packages/google-api-core/google/api_core/gapic_v1/method_helpers.py b/packages/google-api-core/google/api_core/gapic_v1/request.py similarity index 89% rename from packages/google-api-core/google/api_core/gapic_v1/method_helpers.py rename to packages/google-api-core/google/api_core/gapic_v1/request.py index a79d23bf6b19..75672aeb7853 100644 --- a/packages/google-api-core/google/api_core/gapic_v1/method_helpers.py +++ b/packages/google-api-core/google/api_core/gapic_v1/request.py @@ -14,10 +14,16 @@ # limitations under the License. # -"""Helpers for method requests.""" +"""Helpers for preparing and structuring API requests. + +This module provides utilities to preprocess request parameters and objects +before invoking API methods, such as automatically generating request IDs +if they are not already set. +""" import uuid from typing import Union + import google.protobuf.message diff --git a/packages/google-api-core/tests/unit/gapic/test_method_helpers.py b/packages/google-api-core/tests/unit/gapic/test_request.py similarity index 98% rename from packages/google-api-core/tests/unit/gapic/test_method_helpers.py rename to packages/google-api-core/tests/unit/gapic/test_request.py index 61189a72a965..5cdb66ce05e4 100644 --- a/packages/google-api-core/tests/unit/gapic/test_method_helpers.py +++ b/packages/google-api-core/tests/unit/gapic/test_request.py @@ -14,7 +14,8 @@ # limitations under the License. import re -from google.api_core.gapic_v1.method_helpers import setup_request_id + +from google.api_core.gapic_v1.request import setup_request_id def test_setup_request_id(): From 20adea6b498f00f84def5e9f8378938cb5d7c2cd Mon Sep 17 00:00:00 2001 From: Heba Alazzeh Date: Thu, 16 Jul 2026 21:15:24 +0000 Subject: [PATCH 3/3] chore: address PR review comments for gapic centralization request ID --- .../google/api_core/gapic_v1/request.py | 7 + .../tests/unit/gapic/test_request.py | 187 +++++++++--------- 2 files changed, 97 insertions(+), 97 deletions(-) diff --git a/packages/google-api-core/google/api_core/gapic_v1/request.py b/packages/google-api-core/google/api_core/gapic_v1/request.py index 75672aeb7853..90c135d00f8c 100644 --- a/packages/google-api-core/google/api_core/gapic_v1/request.py +++ b/packages/google-api-core/google/api_core/gapic_v1/request.py @@ -34,6 +34,13 @@ def setup_request_id( ) -> None: """Populate a UUID4 field in the request if it is not already set. + This helper is used to ensure request idempotency by automatically + generating a unique identifier (such as `request_id`) for requests + that support it. If a request is retried, the same identifier can be + sent on subsequent retries, allowing the server to recognize the retried + request and prevent duplicate processing (e.g., creating duplicate + resources). + Args: request (Union[google.protobuf.message.Message, dict]): The request object. diff --git a/packages/google-api-core/tests/unit/gapic/test_request.py b/packages/google-api-core/tests/unit/gapic/test_request.py index 5cdb66ce05e4..8fc4d2f110de 100644 --- a/packages/google-api-core/tests/unit/gapic/test_request.py +++ b/packages/google-api-core/tests/unit/gapic/test_request.py @@ -15,105 +15,98 @@ import re -from google.api_core.gapic_v1.request import setup_request_id - - -def test_setup_request_id(): - class MockRequest: - def __init__(self, **kwargs): - for k, v in kwargs.items(): - setattr(self, k, v) - - def __contains__(self, key): - return hasattr(self, key) - - class MockProtoRequest: - def __init__(self, **kwargs): - for k, v in kwargs.items(): - setattr(self, k, v) - - def HasField(self, key): - return hasattr(self, key) - - # Test with proto3 optional field not in request - request = MockRequest() - setup_request_id(request, "request_id", True) - assert re.match( - r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", - request.request_id, - ) - - # Test with proto3 optional field already in request - request = MockRequest(request_id="already_set") - setup_request_id(request, "request_id", True) - assert request.request_id == "already_set" - - # Test with non-proto3 optional field empty - request = MockRequest(request_id="") - setup_request_id(request, "request_id", False) - assert re.match( - r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", - request.request_id, - ) +import pytest - # Test with non-proto3 optional field already set - request = MockRequest(request_id="already_set") - setup_request_id(request, "request_id", False) - assert request.request_id == "already_set" - - # Test with proto3 optional field not in request (MockProtoRequest) - request = MockProtoRequest() - setup_request_id(request, "request_id", True) - assert re.match( - r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", - request.request_id, - ) - - # Test with proto3 optional field already in request (MockProtoRequest) - request = MockProtoRequest(request_id="already_set") - setup_request_id(request, "request_id", True) - assert request.request_id == "already_set" - - # Test with ValueError - class MockValueErrorRequest: - def HasField(self, key): - raise ValueError("Mismatched field") - - def __contains__(self, key): - return hasattr(self, key) - - request = MockValueErrorRequest() - setup_request_id(request, "request_id", True) - assert re.match( - r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", - request.request_id, - ) +from google.api_core.gapic_v1.request import setup_request_id - # Test with dict and proto3 optional field not in request - request = {} - setup_request_id(request, "request_id", True) - assert re.match( - r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", - request["request_id"], - ) - # Test with dict and proto3 optional field already in request - request = {"request_id": "already_set"} - setup_request_id(request, "request_id", True) - assert request["request_id"] == "already_set" - - # Test with dict and non-proto3 optional field empty - request = {"request_id": ""} - setup_request_id(request, "request_id", False) - assert re.match( - r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", - request["request_id"], +# --- Mock Request Helper Classes --- + + +class MockRequest: + def __init__(self, **kwargs): + for k, v in kwargs.items(): + setattr(self, k, v) + + def __contains__(self, key): + return hasattr(self, key) + + +class MockProtoRequest: + def __init__(self, **kwargs): + for k, v in kwargs.items(): + setattr(self, k, v) + + def HasField(self, key): + return hasattr(self, key) + + +class MockValueErrorRequest: + def HasField(self, key): + raise ValueError("Mismatched field") + + def __contains__(self, key): + return hasattr(self, key) + + +# --- Parameterized Test --- + +UUID_REGEX = r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}" + + +@pytest.mark.parametrize( + "request_obj, is_proto3_optional, expected", + [ + # MockRequest cases + (MockRequest(), True, "uuid"), + (MockRequest(request_id="already_set"), True, "already_set"), + (MockRequest(request_id=""), False, "uuid"), + (MockRequest(request_id="already_set"), False, "already_set"), + # MockProtoRequest cases + (MockProtoRequest(), True, "uuid"), + (MockProtoRequest(request_id="already_set"), True, "already_set"), + # ValueError case + (MockValueErrorRequest(), True, "uuid"), + # Dict cases + ({}, True, "uuid"), + ({"request_id": "already_set"}, True, "already_set"), + ({"request_id": ""}, False, "uuid"), + ({"request_id": "already_set"}, False, "already_set"), + # None case + (None, True, "none"), + ], + ids=[ + "proto3_optional_not_in_request", + "proto3_optional_already_in_request", + "non_proto3_optional_empty", + "non_proto3_optional_already_set", + "proto3_optional_not_in_request_proto", + "proto3_optional_already_in_request_proto", + "value_error_fallback", + "dict_proto3_optional_not_in_request", + "dict_proto3_optional_already_in_request", + "dict_non_proto3_optional_empty", + "dict_non_proto3_optional_already_set", + "none_request", + ], +) +def test_setup_request_id(request_obj, is_proto3_optional, expected): + # Act + setup_request_id(request_obj, "request_id", is_proto3_optional) + + # Assert + if expected == "none": + assert request_obj is None + return + + # Extract the resulting value depending on container type + value = ( + request_obj["request_id"] + if isinstance(request_obj, dict) + else request_obj.request_id ) - # Test with dict and non-proto3 optional field already set - request = {"request_id": "already_set"} - setup_request_id(request, "request_id", False) - assert request["request_id"] == "already_set" - - # Test with None request (should handle gracefully without raising exception) - setup_request_id(None, "request_id", True) + if expected == "uuid": + assert re.match(UUID_REGEX, value) + else: + assert value == expected