diff --git a/.kokoro/system.sh b/.kokoro/system.sh index bdb56214026a..d8a30a58e2fc 100755 --- a/.kokoro/system.sh +++ b/.kokoro/system.sh @@ -68,6 +68,14 @@ run_package_test() { trap 'rm -rf "$gcloud_config_dir"' EXIT case "${package_name}" in + "gapic-generator") + source "${KOKORO_GFILE_DIR}/gapic-generator-env.sh" + PROJECT_ID=$(cat "${KOKORO_GFILE_DIR}/gapic-generator-project-id.json") + GOOGLE_APPLICATION_CREDENTIALS="${KOKORO_GFILE_DIR}/gapic-generator-service-account.json" + export GOOGLE_ADS_VIDEO_PATH="${KOKORO_GFILE_DIR}/gapic-generator-video.mp4" + NOX_FILE="noxfile.py" + NOX_SESSION="${NOX_SESSION:-system-3.12}" + ;; "google-auth") # Copy files needed for google-auth system tests mkdir -p "${package_path}/system_tests/data" @@ -86,14 +94,6 @@ run_package_test() { NOX_FILE="noxfile.py" fi ;; - "google-cloud-dns") - # EXPERIMENTAL: Force running all system sessions to test mixed results. This will be reverted - # before merge. You can safely ignore it. - PROJECT_ID=$(cat "${KOKORO_GFILE_DIR}/project-id.json") - GOOGLE_APPLICATION_CREDENTIALS="${KOKORO_GFILE_DIR}/service-account.json" - NOX_FILE="noxfile.py" - NOX_SESSION="system" - ;; *) PROJECT_ID=$(cat "${KOKORO_GFILE_DIR}/project-id.json") GOOGLE_APPLICATION_CREDENTIALS="${KOKORO_GFILE_DIR}/service-account.json" diff --git a/packages/gapic-generator/noxfile.py b/packages/gapic-generator/noxfile.py index 289be91feb81..a266d080ac93 100644 --- a/packages/gapic-generator/noxfile.py +++ b/packages/gapic-generator/noxfile.py @@ -978,12 +978,120 @@ def format(session): ) +@contextmanager +def google_ads_library(session): + """Generate and install the Google Ads GAPIC library for live system tests.""" + session.install("-e", ".") + session.install("grpcio-tools", "pyYAML", "pypandoc-binary==1.16.2") + + with tempfile.TemporaryDirectory() as tmp_dir: + googleapis_dir = path.join(tmp_dir, "googleapis") + sdk_dir = path.join(tmp_dir, "sdk") + os.makedirs(sdk_dir, exist_ok=True) + + session.run( + "git", + "clone", + "--depth", + "1", + "--filter=blob:none", + "--sparse", + "https://github.com/googleapis/googleapis.git", + googleapis_dir, + external=True, + silent=True, + ) + session.run( + "git", + "-C", + googleapis_dir, + "sparse-checkout", + "set", + "google/ads/googleads/v23", + "google/api", + "google/rpc", + "google/longrunning", + "google/type", + external=True, + silent=True, + ) + + src_yaml = path.join( + googleapis_dir, "google", "ads", "googleads", "v23", "googleads_v23.yaml" + ) + dst_yaml = path.join(sdk_dir, "googleads_v23.yaml") + shutil.copyfile(src_yaml, dst_yaml) + session.run( + "python", + "-c", + ( + "import yaml; " + f"p = {dst_yaml!r}; " + "data = yaml.safe_load(open(p, encoding='utf-8')); " + "data['apis'] = [{'name': 'google.ads.googleads.v23.services.YouTubeVideoUploadService'}]; " + "data.setdefault('publishing', {})['library_settings'] = [" + "{'version': 'google.ads.googleads.v23', " + "'python_settings': {'experimental_features': {'rest_async_io_enabled': True}}}" + "]; " + "yaml.safe_dump(data, open(p, 'w', encoding='utf-8'), default_flow_style=False, sort_keys=False)" + ), + ) + + ads_protos = ( + "google/ads/googleads/v23/services/youtube_video_upload_service.proto", + "google/ads/googleads/v23/resources/youtube_video_upload.proto", + "google/ads/googleads/v23/enums/youtube_video_privacy.proto", + ) + desc_path = path.join(sdk_dir, "googleads.desc") + session.run( + "python", + "-m", + "grpc_tools.protoc", + "--experimental_allow_proto3_optional", + f"--proto_path={googleapis_dir}", + "--include_imports", + "--include_source_info", + f"-o{desc_path}", + *(path.join(googleapis_dir, p) for p in ads_protos), + external=True, + ) + + retry_config = path.join( + googleapis_dir, + "google", + "ads", + "googleads", + "v23", + "googleads_grpc_service_config.json", + ) + session.run( + "python", + "-m", + "grpc_tools.protoc", + "--experimental_allow_proto3_optional", + f"--descriptor_set_in={desc_path}", + ( + "--python_gapic_opt=" + f"transport=grpc+rest,autogen-snippets=False,service-yaml={dst_yaml},retry-config={retry_config}" + ), + f"--python_gapic_out={sdk_dir}", + *ads_protos, + external=True, + ) + + session.install("-e", f"{sdk_dir}[async_rest]") + yield sdk_dir + + @nox.session(python=ALL_PYTHON) def system(session): - # TODO(https://github.com/googleapis/google-cloud-python/issues/16190): - # Implement system test session. - """Run the system test suite (skipped for migration).""" - session.skip(f"system session is not yet implemented for gapic-generator-python.") + """Run the system test suite.""" + with google_ads_library(session): + session.install("pytest", "pytest-asyncio") + session.run( + "py.test", + *(session.posargs or ["-vv", path.join("tests", "system_live")]), + ) @nox.session(python=NEWEST_PYTHON) diff --git a/packages/gapic-generator/tests/system/conftest.py b/packages/gapic-generator/tests/system/conftest.py index ac6de38eda0b..83e92aadca2a 100644 --- a/packages/gapic-generator/tests/system/conftest.py +++ b/packages/gapic-generator/tests/system/conftest.py @@ -38,8 +38,16 @@ HAS_GOOGLE_AUTH_AIO = False import google.auth from google.auth import credentials as ga_credentials + from google.showcase import EchoClient, IdentityClient, MessagingClient +try: + from google.showcase import ResumableUploadServiceClient + HAS_RESUMABLE_UPLOAD_CLIENT = True +except ImportError: + HAS_RESUMABLE_UPLOAD_CLIENT = False + +HAS_ASYNC_REST_RESUMABLE_UPLOAD_TRANSPORT = False if os.environ.get("GAPIC_PYTHON_ASYNC", "true") == "true": import asyncio @@ -63,6 +71,16 @@ HAS_ASYNC_REST_IDENTITY_TRANSPORT = True except: HAS_ASYNC_REST_IDENTITY_TRANSPORT = False + try: + from google.showcase import ResumableUploadServiceAsyncClient + from google.showcase_v1beta1.services.resumable_upload_service.transports.rest_asyncio import ( + AsyncResumableUploadServiceRestTransport, + AsyncResumableUploadServiceRestInterceptor, + ) + + HAS_ASYNC_REST_RESUMABLE_UPLOAD_TRANSPORT = True + except: + HAS_ASYNC_REST_RESUMABLE_UPLOAD_TRANSPORT = False _GRPC_VERSION = grpc.__version__ @@ -83,7 +101,9 @@ def async_anonymous_credentials(): @pytest.fixture def event_loop(): - return asyncio.get_event_loop() + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + return loop @pytest_asyncio.fixture(params=["grpc_asyncio", "rest_asyncio"]) def async_echo(use_mtls, request, event_loop): @@ -296,6 +316,33 @@ def post_expand_with_metadata(self, request, metadata): return request, metadata +if HAS_RESUMABLE_UPLOAD_CLIENT: + try: + from google.showcase_v1beta1.services.resumable_upload_service.transports import ( + ResumableUploadServiceRestInterceptor, + ) + + class ResumableUploadMetadataClientRestInterceptor( + ResumableUploadServiceRestInterceptor + ): + request_metadata: Sequence[Tuple[str, str]] = [] + response_metadata: Sequence[Tuple[str, str]] = [] + + def pre_upload_media(self, request, metadata): + self.request_metadata = metadata + return request, metadata + + def post_upload_media_with_metadata(self, request, metadata): + self.response_metadata = metadata + return request, metadata + + HAS_RESUMABLE_UPLOAD_INTERCEPTOR = True + except ImportError: + HAS_RESUMABLE_UPLOAD_INTERCEPTOR = False +else: + HAS_RESUMABLE_UPLOAD_INTERCEPTOR = False + + if HAS_ASYNC_REST_ECHO_TRANSPORT: class EchoMetadataClientRestAsyncInterceptor(AsyncEchoRestInterceptor): @@ -319,6 +366,19 @@ async def post_expand_with_metadata(self, request, metadata): return request, metadata +if HAS_ASYNC_REST_RESUMABLE_UPLOAD_TRANSPORT: + + class ResumableUploadMetadataClientRestAsyncInterceptor( + AsyncResumableUploadServiceRestInterceptor + ): + request_metadata: Sequence[Tuple[str, str]] = [] + response_metadata: Sequence[Tuple[str, str]] = [] + + async def pre_upload_media(self, request, metadata): + self.request_metadata = metadata + return request, metadata + + class EchoMetadataClientGrpcInterceptor( grpc.UnaryUnaryClientInterceptor, grpc.UnaryStreamClientInterceptor, @@ -535,6 +595,144 @@ def intercepted_echo_rest_async(): return EchoAsyncClient(transport=transport), interceptor +@pytest.fixture +def intercepted_resumable_upload_rest(use_mtls, use_tls): + if not HAS_RESUMABLE_UPLOAD_CLIENT or not HAS_RESUMABLE_UPLOAD_INTERCEPTOR: + pytest.skip("ResumableUploadServiceClient not available.") + + transport_name = "rest" + transport_cls = ResumableUploadServiceClient.get_transport_class(transport_name) + interceptor = ResumableUploadMetadataClientRestInterceptor() + + url_scheme = "https" if (use_mtls or use_tls) else "http" + transport = transport_cls( + credentials=ga_credentials.AnonymousCredentials(), + host="localhost:7469", + url_scheme=url_scheme, + interceptor=interceptor, + ) + if use_mtls or use_tls: + transport._session.verify = CERT_PATH + transport._session.mount("https://", HostNameIgnoringAdapter()) + if use_mtls: + transport._session.cert = (CERT_PATH, KEY_PATH) + + return ResumableUploadServiceClient(transport=transport), interceptor + + +@pytest.fixture +def intercepted_resumable_upload_rest_async(): + if not HAS_ASYNC_REST_RESUMABLE_UPLOAD_TRANSPORT: + pytest.skip("Skipping test with async rest.") + + interceptor = ResumableUploadMetadataClientRestAsyncInterceptor() + + transport = AsyncResumableUploadServiceRestTransport( + credentials=async_anonymous_credentials(), + host="localhost:7469", + url_scheme="http", + interceptor=interceptor, + ) + + return ResumableUploadServiceAsyncClient(transport=transport), interceptor + + +try: + from google.api_core.resumable_transfer import ( + ResumableUploadConfig, + ResumableUploadSession, + ) +except ImportError: + ResumableUploadConfig = None + ResumableUploadSession = None + + +def make_resumable_upload( + transport, + request_body, + stream, + upload_url, + size=None, + config=None, + **kwargs, +): + content_type = kwargs.pop("content_type", "application/octet-stream") + response_type = kwargs.pop("response_type", None) + retry = kwargs.pop("retry", None) + timeout = kwargs.pop("timeout", None) + + if config is None: + config = ResumableUploadConfig(**kwargs) + elif kwargs: + for k, v in kwargs.items(): + if hasattr(config, k): + setattr(config, k, v) + + # ``make_resumable_upload`` instantiates ``ResumableUploadSession`` directly + # rather than calling the generated GAPIC client method (``client.upload_media``). + # Unlike the generated GAPIC REST transport, ``ResumableUploadSession`` in + # ``google-api-core`` is payload-format agnostic and does not set + # ``Content-Type: application/json`` on the start request automatically. + headers = dict(config.start_headers or []) + if request_body and "Content-Type" not in headers: + headers["Content-Type"] = "application/json" + config.headers = headers + + session = ResumableUploadSession( + upload_url=upload_url, + config=config, + content_type=content_type, + response_type=response_type, + transport=transport, + ) + return session.upload( + stream=stream, + request_body=request_body, + content_type=content_type, + size=size, + transport=transport, + retry=retry, + timeout=timeout, + ) + + +def resume_resumable_upload( + transport, + upload_url, + stream, + size=None, + config=None, + **kwargs, +): + content_type = kwargs.pop("content_type", None) + response_type = kwargs.pop("response_type", None) + retry = kwargs.pop("retry", None) + timeout = kwargs.pop("timeout", None) + + if config is None: + config = ResumableUploadConfig(**kwargs) + elif kwargs: + for k, v in kwargs.items(): + if hasattr(config, k): + setattr(config, k, v) + + session = ResumableUploadSession( + upload_url=upload_url, + config=config, + transport=transport, + content_type=content_type, + response_type=response_type, + ) + return session.resume( + upload_url=upload_url, + stream=stream, + size=size, + transport=transport, + retry=retry, + timeout=timeout, + ) + + def pytest_terminal_summary(terminalreporter, exitstatus, config): """Prints a Telemetry Span Compliance summary to the console. diff --git a/packages/gapic-generator/tests/system/test_resumable_upload_progress.py b/packages/gapic-generator/tests/system/test_resumable_upload_progress.py new file mode 100644 index 000000000000..3528f45d405b --- /dev/null +++ b/packages/gapic-generator/tests/system/test_resumable_upload_progress.py @@ -0,0 +1,780 @@ +# 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 +# +# https://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. + +"""System tests for GAPIC Resumable Upload progress tracking and Golden Path workflows. + +Covers both direct `ResumableUploadSession` usage and end-to-end client-level +execution (`ResumableUploadServiceClient` and `ResumableUploadServiceAsyncClient`) +across default `grpc`/`grpc_asyncio` and `rest`/`rest_asyncio` transports, +including the generated sample patterns: +- Option 1: Direct upload from start to completion +- Option 2: Upload while receiving progress updates +- Section 2.1 Golden Path Suite (multi-chunk known size, small upload default + chunk size, standalone finalize on unseekable stream) +""" + +import io +import os +from typing import List +import pytest + +from google.auth import credentials as ga_credentials +from google.api_core.resumable_transfer import ( + DEFAULT_CHUNK_SIZE, + ProgressState, + ResumableUploadConfig, + ResumableUploadSession, + UploadProgress, +) +from google.showcase import ( + ResumableUploadServiceClient, + UploadMediaRequest, + UploadMediaResponse, +) + +from conftest import ( + HAS_ASYNC_REST_RESUMABLE_UPLOAD_TRANSPORT, + make_resumable_upload, +) + +if os.environ.get("GAPIC_PYTHON_ASYNC", "true") == "true": + from conftest import async_anonymous_credentials + from google.showcase import ResumableUploadServiceAsyncClient + +SHOWCASE_API_ENDPOINT = "http://localhost:7469" + + +class _UnknownSizeUnseekableStream: + """Stream wrapper with unknown total size and non-seekable semantics.""" + + def __init__(self, data: bytes) -> None: + self._buf = io.BytesIO(data) + + def read(self, size: int = -1) -> bytes: + return self._buf.read(size) + + def seekable(self) -> bool: + return False + + +def test_make_resumable_upload_end_to_end(intercepted_resumable_upload_rest): + client, _ = intercepted_resumable_upload_rest + stream = io.BytesIO(b"0123456789" * 100) + + # Use make_resumable_upload from start to finish + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + request_body = '{"name": "full_e2e_upload.txt"}' + + response = make_resumable_upload( + transport=client.transport._session, + request_body=request_body, + stream=stream, + upload_url=initial_url, + chunk_size=256, + ) + assert isinstance(response, bytes) + + final_response = UploadMediaResponse.from_json(response) + assert final_response.name == "full_e2e_upload.txt" + assert final_response.size == len(stream.getvalue()) + + +def test_resumable_upload_generator_progress_tracking(intercepted_resumable_upload_rest): + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + payload = b"0123456789" * 100 + stream = io.BytesIO(payload) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "chunk_granularity"), + ("Content-Type", "application/json"), + ] + config = ResumableUploadConfig( + chunk_size=256, + headers=scenario_headers, + ) + session = ResumableUploadSession( + upload_url=initial_url, + config=config, + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + + progress_list = [] + # PEP 255 generator progress tracking + for progress in session.iter_upload(stream, request_body='{"name": "generator_upload.txt"}'): + progress_list.append(progress) + assert isinstance(progress, UploadProgress) + assert "sid=" in progress.upload_url + assert progress.chunk_size == 256 + assert progress.total_bytes == len(payload) + + # Verify yielded snapshots + assert len(progress_list) >= 3 + assert progress_list[0].state == ProgressState.STARTED + assert progress_list[-1].state == ProgressState.FINALIZED + assert progress_list[-1].bytes_uploaded == len(payload) + + # Verify response populated on session after generator exhaustion + assert session.response is not None + assert session.response.name == "generator_upload.txt" + assert session.response.size == len(payload) + + +def test_resumable_upload_unseekable_stream_recovery(intercepted_resumable_upload_rest): + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + request_body = '{"name": "unseekable_stream_upload.txt"}' + + class UnseekableStream(io.BytesIO): + def seekable(self): + return False + + def seek(self, offset, whence=io.SEEK_SET): + raise io.UnsupportedOperation("Stream is not seekable") + + data = b"B" * 1024 + stream = UnseekableStream(data) + + # Injects 503 error on first chunk attempt, which ResumableUploadSession recovers via in-memory buffer + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_chunk_upload"), + ("X-Goog-Test-Scenario-Config", '{"error_code":503,"failure_count":1,"after_offset":0}'), + ] + + response = make_resumable_upload( + transport=client.transport._session, + request_body=request_body, + stream=stream, + upload_url=initial_url, + size=len(data), + chunk_size=512, + headers=scenario_headers, + ) + assert isinstance(response, bytes) + final_response = UploadMediaResponse.from_json(response) + assert final_response.name == "unseekable_stream_upload.txt" + assert final_response.size == len(data) + + +def test_multi_chunk_known_size(intercepted_resumable_upload_rest): + """Golden Path Case 1: Multi-chunk upload with known size (1.5 MB, 512 KiB chunks).""" + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + total_size = 1_500_000 + chunk_size = 524_288 # 512 KiB + payload = b"M" * total_size + stream = io.BytesIO(payload) + + config = ResumableUploadConfig( + chunk_size=chunk_size, + headers={"Content-Type": "application/json"}, + ) + session = ResumableUploadSession( + upload_url=initial_url, + config=config, + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + + progress_records = list( + session.iter_upload( + stream, + request_body='{"name": "multi_chunk_known_size.mp4"}', + size=total_size, + ) + ) + + assert isinstance(session.response, UploadMediaResponse) + assert session.response.name == "multi_chunk_known_size.mp4" + assert session.response.size == total_size + + phases = [p.state for p in progress_records] + offsets = [p.bytes_uploaded for p in progress_records] + assert phases == [ + ProgressState.STARTED, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.FINALIZED, + ] + assert offsets == [0, 524_288, 1_048_576, 1_500_000] + assert all(p.total_bytes == total_size for p in progress_records) + + +def test_small_upload_default_chunk_size(intercepted_resumable_upload_rest): + """Golden Path Case 2: Default chunk size on small upload (~100 KB).""" + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + total_size = 100_000 + payload = b"S" * total_size + stream = io.BytesIO(payload) + + config = ResumableUploadConfig(headers={"Content-Type": "application/json"}) + session = ResumableUploadSession( + upload_url=initial_url, + config=config, + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + + progress_records = list( + session.iter_upload( + stream, + request_body='{"name": "small_default_chunk.bin"}', + size=total_size, + ) + ) + + assert session.chunk_size <= DEFAULT_CHUNK_SIZE + assert isinstance(session.response, UploadMediaResponse) + assert session.response.name == "small_default_chunk.bin" + assert session.response.size == total_size + + phases = [p.state for p in progress_records] + offsets = [p.bytes_uploaded for p in progress_records] + assert phases == [ + ProgressState.STARTED, + ProgressState.FINALIZED, + ] + assert offsets == [0, 100_000] + assert all(p.total_bytes == total_size for p in progress_records) + + +def test_standalone_finalize_unseekable_stream(intercepted_resumable_upload_rest): + """Golden Path Case 3: Unseekable stream with unknown size and exact chunk multiple.""" + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + chunk_size = 262_144 # 256 KiB + total_size = 3 * chunk_size # 786_432 bytes + payload = b"U" * total_size + stream = _UnknownSizeUnseekableStream(payload) + + config = ResumableUploadConfig( + chunk_size=chunk_size, + headers={"Content-Type": "application/json"}, + ) + session = ResumableUploadSession( + upload_url=initial_url, + config=config, + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + + progress_records = list( + session.iter_upload( + stream, + request_body='{"name": "unseekable_exact_multiple.bin"}', + size=None, + ) + ) + + assert isinstance(session.response, UploadMediaResponse) + assert session.response.name == "unseekable_exact_multiple.bin" + assert session.response.size == total_size + + phases = [p.state for p in progress_records] + offsets = [p.bytes_uploaded for p in progress_records] + assert phases == [ + ProgressState.STARTED, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.FINALIZED, + ] + assert offsets == [0, 262_144, 524_288, 786_432, 786_432] + assert all(p.total_bytes is None for p in progress_records) + + +def test_client_upload_media_passes_request_body(intercepted_resumable_upload_rest): + """ Verify that invoking `client.upload_media(request=UploadMediaRequest(...))` + and calling `session.upload(stream=...)` forwards the serialized request body + on the initial upload session start request so the server echoes back the + expected resource name in `UploadMediaResponse.name`.""" + client, _ = intercepted_resumable_upload_rest + payload = b"0123456789" * 100 + stream = io.BytesIO(payload) + + session = client.upload_media( + request=UploadMediaRequest(name="client_upload_media.txt"), + ) + response = session.upload(stream=stream) + + assert isinstance(response, UploadMediaResponse) + assert response.name == "client_upload_media.txt" + assert response.size == len(payload) + + +def test_client_upload_media_with_custom_headers_sets_json_content_type( + intercepted_resumable_upload_rest, +): + """Verify that `client.upload_media` sets `Content-Type: application/json` + on the start request and forwards both `metadata` and `config.headers`.""" + client, interceptor = intercepted_resumable_upload_rest + payload = b"0123456789" * 100 + stream = io.BytesIO(payload) + # Pass the scenario header via `metadata` so the server only replies with + # `X-Goog-Upload-Chunk-Granularity: 256` (aligning chunk_size=300 -> 512 + # instead of the default 262,144) if `metadata` is sent on the start request. + request_metadata = ("X-Goog-Test-Scenario", "chunk_granularity") + + session = client.upload_media( + request=UploadMediaRequest(name="custom_headers_upload_media.txt"), + config=ResumableUploadConfig( + chunk_size=300, + headers=[("x-custom-config-header", "config-value")], + ), + metadata=[request_metadata], + ) + response = session.upload(stream=stream) + + assert request_metadata in interceptor.request_metadata + assert session.chunk_size == 512 + assert isinstance(response, UploadMediaResponse) + assert response.name == "custom_headers_upload_media.txt" + assert response.size == len(payload) + + +if os.environ.get("GAPIC_PYTHON_ASYNC", "true") == "true": + + @pytest.mark.asyncio + async def test_async_client_upload_media_end_to_end( + intercepted_resumable_upload_rest_async, + ): + """Verify end-to-end async resumable upload via ResumableUploadServiceAsyncClient.""" + client, _ = intercepted_resumable_upload_rest_async + payload = b"0123456789" * 100 + stream = io.BytesIO(payload) + + session = await client.upload_media( + request=UploadMediaRequest(name="async_client_upload_media.txt"), + config=ResumableUploadConfig(chunk_size=256), + ) + response = await session.upload(stream=stream) + + assert isinstance(response, UploadMediaResponse) + assert response.name == "async_client_upload_media.txt" + assert response.size == len(payload) + + @pytest.mark.asyncio + async def test_async_client_upload_media_with_custom_headers_sets_json_content_type( + intercepted_resumable_upload_rest_async, + ): + """Verify that `async_client.upload_media` sets `Content-Type: application/json` + on the start request and forwards both `metadata` and `config.headers`.""" + client, interceptor = intercepted_resumable_upload_rest_async + payload = b"0123456789" * 100 + stream = io.BytesIO(payload) + request_metadata = ("X-Goog-Test-Scenario", "chunk_granularity") + + session = await client.upload_media( + request=UploadMediaRequest(name="async_custom_headers_upload_media.txt"), + config=ResumableUploadConfig( + chunk_size=300, + headers=[("x-custom-config-header", "config-value")], + ), + metadata=[request_metadata], + ) + response = await session.upload(stream=stream) + + assert request_metadata in interceptor.request_metadata + assert session.chunk_size == 512 + assert isinstance(response, UploadMediaResponse) + assert response.name == "async_custom_headers_upload_media.txt" + assert response.size == len(payload) + + @pytest.mark.asyncio + async def test_async_client_upload_media_progress_tracking( + intercepted_resumable_upload_rest_async, + ): + """Verify async progress iteration over ResumableUploadServiceAsyncClient.upload_media.""" + client, _ = intercepted_resumable_upload_rest_async + chunk_size = 262_144 # 256 KiB + total_size = 600_000 + payload = b"A" * total_size + stream = io.BytesIO(payload) + + session = await client.upload_media( + request=UploadMediaRequest(name="async_multi_chunk.bin"), + config=ResumableUploadConfig(chunk_size=chunk_size), + ) + + progress_records = [ + p async for p in session.upload(stream=stream, size=total_size) + ] + + assert isinstance(session.response, UploadMediaResponse) + assert session.response.name == "async_multi_chunk.bin" + assert session.response.size == total_size + + phases = [p.state for p in progress_records] + offsets = [p.bytes_uploaded for p in progress_records] + assert phases == [ + ProgressState.STARTED, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.FINALIZED, + ] + assert offsets == [0, 262_144, 524_288, 600_000] + assert all(p.total_bytes == total_size for p in progress_records) + + +# ============================================================================= +# End-to-End Client Sample Patterns & 2.1 Golden Path Suite (gRPC & REST) +# ============================================================================= + + +def _create_sync_client(transport, monkeypatch): + monkeypatch.setenv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "false") + return ResumableUploadServiceClient( + credentials=ga_credentials.AnonymousCredentials(), + client_options={"api_endpoint": SHOWCASE_API_ENDPOINT}, + transport=transport, + ) + + +@pytest.mark.parametrize("transport", ["grpc", "rest"]) +def test_sample_option_1_direct_upload(transport, monkeypatch): + """Option 1: Direct upload from start to completion across gRPC and REST transports.""" + client = _create_sync_client(transport, monkeypatch) + payload = b"0123456789" * 100 + stream = io.BytesIO(payload) + + request = UploadMediaRequest(name="sample_option_1_sync.txt") + config = ResumableUploadConfig(chunk_size=262_144) + + upload_session = client.upload_media(request=request, config=config) + response = upload_session.upload(stream) + + assert isinstance(response, UploadMediaResponse) + assert response.name == "sample_option_1_sync.txt" + assert response.size == len(payload) + + +@pytest.mark.parametrize("transport", ["grpc", "rest"]) +def test_sample_option_2_progress_tracking_upload(transport, monkeypatch): + """Option 2: Upload while receiving progress updates across gRPC and REST transports.""" + client = _create_sync_client(transport, monkeypatch) + chunk_size = 262_144 # 256 KiB + total_size = 600_000 + payload = b"P" * total_size + stream = io.BytesIO(payload) + + request = UploadMediaRequest(name="sample_option_2_sync.bin") + config = ResumableUploadConfig(chunk_size=chunk_size) + + upload_session = client.upload_media(request=request, config=config) + progress_snapshots: List[UploadProgress] = [] + for progress in upload_session.iter_upload(stream): + progress_snapshots.append(progress) + + response = upload_session.response + assert isinstance(response, UploadMediaResponse) + assert response.name == "sample_option_2_sync.bin" + assert response.size == total_size + assert [p.state for p in progress_snapshots] == [ + ProgressState.STARTED, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.FINALIZED, + ] + assert [p.bytes_uploaded for p in progress_snapshots] == [ + 0, + 262_144, + 524_288, + total_size, + ] + + +# ----------------------------------------------------------------------------- +# 2.1 Golden Path Suite (End-to-End Client across gRPC & REST) +# ----------------------------------------------------------------------------- + + +@pytest.mark.parametrize("transport", ["grpc", "rest"]) +def test_client_multi_chunk_known_size(transport, monkeypatch): + """Golden Path Case 1: Multi-chunk upload with known size (1.5 MB, 512 KiB chunks).""" + client = _create_sync_client(transport, monkeypatch) + total_size = 1_500_000 + chunk_size = 524_288 # 512 KiB + stream = io.BytesIO(b"M" * total_size) + + upload_session = client.upload_media( + request=UploadMediaRequest(name="multi_chunk_known_size.mp4"), + config=ResumableUploadConfig(chunk_size=chunk_size), + ) + progress_records = list( + upload_session.iter_upload(stream, size=total_size) + ) + + assert isinstance(upload_session.response, UploadMediaResponse) + assert upload_session.response.name == "multi_chunk_known_size.mp4" + assert upload_session.response.size == total_size + assert [p.state for p in progress_records] == [ + ProgressState.STARTED, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.FINALIZED, + ] + assert [p.bytes_uploaded for p in progress_records] == [ + 0, + 524_288, + 1_048_576, + 1_500_000, + ] + assert all(p.total_bytes == total_size for p in progress_records) + + +@pytest.mark.parametrize("transport", ["grpc", "rest"]) +def test_client_small_upload_default_chunk_size(transport, monkeypatch): + """Golden Path Case 2: Default chunk size on small upload (~100 KB).""" + client = _create_sync_client(transport, monkeypatch) + total_size = 100_000 + stream = io.BytesIO(b"S" * total_size) + + upload_session = client.upload_media( + request=UploadMediaRequest(name="small_default_chunk.bin"), + ) + progress_records = list( + upload_session.iter_upload(stream, size=total_size) + ) + + assert upload_session.chunk_size <= DEFAULT_CHUNK_SIZE + assert isinstance(upload_session.response, UploadMediaResponse) + assert upload_session.response.name == "small_default_chunk.bin" + assert upload_session.response.size == total_size + assert [p.state for p in progress_records] == [ + ProgressState.STARTED, + ProgressState.FINALIZED, + ] + assert [p.bytes_uploaded for p in progress_records] == [0, 100_000] + assert all(p.total_bytes == total_size for p in progress_records) + + +@pytest.mark.parametrize("transport", ["grpc", "rest"]) +def test_client_standalone_finalize_unseekable_stream(transport, monkeypatch): + """Golden Path Case 3: Unseekable stream with unknown size and exact chunk multiple.""" + client = _create_sync_client(transport, monkeypatch) + chunk_size = 262_144 # 256 KiB + total_size = 3 * chunk_size # 786_432 bytes + stream = _UnknownSizeUnseekableStream(b"U" * total_size) + + upload_session = client.upload_media( + request=UploadMediaRequest(name="unseekable_exact_multiple.bin"), + config=ResumableUploadConfig(chunk_size=chunk_size), + ) + progress_records = list(upload_session.iter_upload(stream, size=None)) + + assert isinstance(upload_session.response, UploadMediaResponse) + assert upload_session.response.name == "unseekable_exact_multiple.bin" + assert upload_session.response.size == total_size + assert [p.state for p in progress_records] == [ + ProgressState.STARTED, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.FINALIZED, + ] + assert [p.bytes_uploaded for p in progress_records] == [ + 0, + 262_144, + 524_288, + 786_432, + 786_432, + ] + assert all(p.total_bytes is None for p in progress_records) + + +if os.environ.get("GAPIC_PYTHON_ASYNC", "true") == "true": + + def _create_async_client(transport, monkeypatch): + monkeypatch.setenv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "false") + if not HAS_ASYNC_REST_RESUMABLE_UPLOAD_TRANSPORT: + pytest.skip("Async REST transport not available.") + if ( + transport == "rest_asyncio" + and "rest_asyncio" + not in ResumableUploadServiceClient._transport_registry + ): + pytest.skip( + "rest_asyncio transport is not registered when rest_async_io_enabled is False." + ) + return ResumableUploadServiceAsyncClient( + credentials=async_anonymous_credentials(), + client_options={"api_endpoint": SHOWCASE_API_ENDPOINT}, + transport=transport, + ) + + @pytest.mark.asyncio + @pytest.mark.parametrize("transport", ["grpc_asyncio", "rest_asyncio"]) + async def test_async_sample_option_1_direct_upload(transport, monkeypatch): + """Async Option 1: Direct upload from start to completion.""" + client = _create_async_client(transport, monkeypatch) + payload = b"0123456789" * 100 + stream = io.BytesIO(payload) + + request = UploadMediaRequest(name="sample_option_1_async.txt") + config = ResumableUploadConfig(chunk_size=262_144) + + upload_session = await client.upload_media( + request=request, config=config + ) + response = await upload_session.upload(stream) + + assert isinstance(response, UploadMediaResponse) + assert response.name == "sample_option_1_async.txt" + assert response.size == len(payload) + + @pytest.mark.asyncio + @pytest.mark.parametrize("transport", ["grpc_asyncio", "rest_asyncio"]) + async def test_async_sample_option_2_progress_tracking_upload( + transport, monkeypatch + ): + """Async Option 2: Upload while receiving progress updates.""" + client = _create_async_client(transport, monkeypatch) + chunk_size = 262_144 # 256 KiB + total_size = 600_000 + payload = b"P" * total_size + stream = io.BytesIO(payload) + + request = UploadMediaRequest(name="sample_option_2_async.bin") + config = ResumableUploadConfig(chunk_size=chunk_size) + + upload_session = await client.upload_media( + request=request, config=config + ) + progress_snapshots: List[UploadProgress] = [] + async for progress in upload_session.upload(stream): + progress_snapshots.append(progress) + + response = upload_session.response + assert isinstance(response, UploadMediaResponse) + assert response.name == "sample_option_2_async.bin" + assert response.size == total_size + assert [p.state for p in progress_snapshots] == [ + ProgressState.STARTED, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.FINALIZED, + ] + assert [p.bytes_uploaded for p in progress_snapshots] == [ + 0, + 262_144, + 524_288, + total_size, + ] + + @pytest.mark.asyncio + @pytest.mark.parametrize("transport", ["grpc_asyncio", "rest_asyncio"]) + async def test_async_multi_chunk_known_size(transport, monkeypatch): + """Async Golden Path Case 1: Multi-chunk upload with known size.""" + client = _create_async_client(transport, monkeypatch) + total_size = 1_500_000 + chunk_size = 524_288 + stream = io.BytesIO(b"M" * total_size) + + upload_session = await client.upload_media( + request=UploadMediaRequest(name="async_multi_chunk_known_size.mp4"), + config=ResumableUploadConfig(chunk_size=chunk_size), + ) + progress_records = [ + p async for p in upload_session.upload(stream, size=total_size) + ] + + assert isinstance(upload_session.response, UploadMediaResponse) + assert upload_session.response.name == "async_multi_chunk_known_size.mp4" + assert upload_session.response.size == total_size + assert [p.state for p in progress_records] == [ + ProgressState.STARTED, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.FINALIZED, + ] + assert [p.bytes_uploaded for p in progress_records] == [ + 0, + 524_288, + 1_048_576, + 1_500_000, + ] + assert all(p.total_bytes == total_size for p in progress_records) + + @pytest.mark.asyncio + @pytest.mark.parametrize("transport", ["grpc_asyncio", "rest_asyncio"]) + async def test_async_small_upload_default_chunk_size( + transport, monkeypatch + ): + """Async Golden Path Case 2: Default chunk size on small upload.""" + client = _create_async_client(transport, monkeypatch) + total_size = 100_000 + stream = io.BytesIO(b"S" * total_size) + + upload_session = await client.upload_media( + request=UploadMediaRequest(name="async_small_default_chunk.bin"), + ) + progress_records = [ + p async for p in upload_session.upload(stream, size=total_size) + ] + + assert upload_session.chunk_size <= DEFAULT_CHUNK_SIZE + assert isinstance(upload_session.response, UploadMediaResponse) + assert upload_session.response.name == "async_small_default_chunk.bin" + assert upload_session.response.size == total_size + assert [p.state for p in progress_records] == [ + ProgressState.STARTED, + ProgressState.FINALIZED, + ] + assert [p.bytes_uploaded for p in progress_records] == [0, 100_000] + assert all(p.total_bytes == total_size for p in progress_records) + + @pytest.mark.asyncio + @pytest.mark.parametrize("transport", ["grpc_asyncio", "rest_asyncio"]) + async def test_async_standalone_finalize_unseekable_stream( + transport, monkeypatch + ): + """Async Golden Path Case 3: Unseekable stream with unknown size and exact chunk multiple.""" + client = _create_async_client(transport, monkeypatch) + chunk_size = 262_144 + total_size = 3 * chunk_size + stream = _UnknownSizeUnseekableStream(b"U" * total_size) + + upload_session = await client.upload_media( + request=UploadMediaRequest( + name="async_unseekable_exact_multiple.bin" + ), + config=ResumableUploadConfig(chunk_size=chunk_size), + ) + progress_records = [ + p async for p in upload_session.upload(stream, size=None) + ] + + assert isinstance(upload_session.response, UploadMediaResponse) + assert ( + upload_session.response.name + == "async_unseekable_exact_multiple.bin" + ) + assert upload_session.response.size == total_size + assert [p.state for p in progress_records] == [ + ProgressState.STARTED, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.FINALIZED, + ] + assert [p.bytes_uploaded for p in progress_records] == [ + 0, + 262_144, + 524_288, + 786_432, + 786_432, + ] + assert all(p.total_bytes is None for p in progress_records) diff --git a/packages/gapic-generator/tests/system/test_resumable_upload_resume.py b/packages/gapic-generator/tests/system/test_resumable_upload_resume.py new file mode 100644 index 000000000000..5845c804f451 --- /dev/null +++ b/packages/gapic-generator/tests/system/test_resumable_upload_resume.py @@ -0,0 +1,973 @@ +# 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 +# +# https://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. + +"""System tests for GAPIC Resumable Upload session resumption workflows. + +Covers both direct `ResumableUploadSession` usage and end-to-end client-level +execution (`ResumableUploadServiceClient` and `ResumableUploadServiceAsyncClient`) +across default `grpc`/`grpc_asyncio` and `rest`/`rest_asyncio` transports, +including the generated sample patterns: +- Option 3: Resume an interrupted upload directly +- Option 4: Resume an interrupted upload while receiving progress updates +- Section 2.5 Resumption Suite (in-progress multi-chunk resumption, finalized upload + recovery, and unseekable stream rejection) +""" + +import io +import os +from typing import List +import pytest + +from google.auth import credentials as ga_credentials +from google.api_core import exceptions +from google.api_core.resumable_transfer import ( + ProgressState, + ResumableUploadConfig, + ResumableUploadSession, + UploadProgress, +) +from google.showcase import ( + ResumableUploadServiceClient, + UploadMediaRequest, + UploadMediaResponse, +) + +from conftest import ( + HAS_ASYNC_REST_RESUMABLE_UPLOAD_TRANSPORT, + resume_resumable_upload, +) + +if os.environ.get("GAPIC_PYTHON_ASYNC", "true") == "true": + from conftest import async_anonymous_credentials + from google.showcase import ResumableUploadServiceAsyncClient + +SHOWCASE_API_ENDPOINT = "http://localhost:7469" + + +class _UnseekableBytesStream: + """File-like stream wrapper that disables seek().""" + + def __init__(self, data: bytes): + self._buf = io.BytesIO(data) + + def read(self, size: int = -1) -> bytes: + return self._buf.read(size) + + def seekable(self) -> bool: + return False + + +class _ClientPauseError(Exception): + """Client-side exception raised inside progress loop to simulate user pause.""" + + def __init__( + self, + message: str, + upload_url: str = "", + chunk_size: int = 0, + ) -> None: + super().__init__(message) + self.upload_url = upload_url + self.chunk_size = chunk_size + + +def _create_sync_client(monkeypatch, transport: str) -> ResumableUploadServiceClient: + # Disable local machine mTLS auto-detection for showcase test endpoints. + monkeypatch.setenv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "false") + return ResumableUploadServiceClient( + credentials=ga_credentials.AnonymousCredentials(), + client_options={"api_endpoint": SHOWCASE_API_ENDPOINT}, + transport=transport, + ) + + + +def test_resumable_upload_resume_direct(intercepted_resumable_upload_rest): + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + request_body = '{"name": "resume_direct.txt"}' + data = b"R" * 2048 + stream = io.BytesIO(data) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "chunk_granularity"), + ("Content-Type", "application/json"), + ] + + # Session 1: Initiate and upload first chunk (512 bytes) + config1 = ResumableUploadConfig( + chunk_size=512, + headers=scenario_headers, + ) + session1 = ResumableUploadSession( + upload_url=initial_url, + config=config1, + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + session1._initiate( + transport=client.transport._session, + request_body=request_body, + size=len(data), + ) + saved_url = session1.upload_url + assert saved_url is not None + + # Transmit only the first chunk + session1._transmit_chunk(client.transport._session, stream, len(data)) + assert session1.bytes_uploaded == 512 + assert not session1.finished + + # Session 2: Fresh session simulating resumption across process boundaries + config2 = ResumableUploadConfig( + chunk_size=512, + ) + session2 = ResumableUploadSession( + config=config2, + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + + # Rewind stream to simulate providing full file stream on resume + stream.seek(0) + final_response = session2.resume( + upload_url=saved_url, + stream=stream, + size=len(data), + transport=client.transport._session, + ) + + assert isinstance(final_response, UploadMediaResponse) + assert final_response.name == "resume_direct.txt" + assert final_response.size == len(data) + assert session2.bytes_uploaded == len(data) + assert session2.finished + + +def test_resumable_upload_iter_resume_generator(intercepted_resumable_upload_rest): + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + request_body = '{"name": "iter_resume.txt"}' + data = b"I" * 1536 + stream = io.BytesIO(data) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "chunk_granularity"), + ("Content-Type", "application/json"), + ] + + config1 = ResumableUploadConfig( + chunk_size=512, + headers=scenario_headers, + ) + session1 = ResumableUploadSession( + upload_url=initial_url, + config=config1, + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + session1._initiate( + transport=client.transport._session, + request_body=request_body, + size=len(data), + ) + saved_url = session1.upload_url + assert saved_url is not None + + # Transmit first chunk + session1._transmit_chunk(client.transport._session, stream, len(data)) + assert session1.bytes_uploaded == 512 + + # Session 2: Resuming with PEP 255 generator iter_resume + config2 = ResumableUploadConfig( + chunk_size=512, + ) + session2 = ResumableUploadSession( + config=config2, + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + + stream.seek(0) + progress_snapshots = list( + session2.iter_resume( + upload_url=saved_url, + stream=stream, + size=len(data), + transport=client.transport._session, + ) + ) + + # First event should be OFFSET_RECEIVED recovering to 512 bytes + assert len(progress_snapshots) >= 2 + offset_event = progress_snapshots[0] + assert offset_event.state == ProgressState.OFFSET_RECEIVED + assert offset_event.bytes_uploaded == 512 + + # Final event should be FINALIZED at 1536 bytes + final_event = progress_snapshots[-1] + assert final_event.state == ProgressState.FINALIZED + assert final_event.bytes_uploaded == len(data) + + assert isinstance(session2.response, UploadMediaResponse) + assert session2.response.name == "iter_resume.txt" + assert session2.response.size == len(data) + + +def test_resumable_upload_resume_chunk_size_override( + intercepted_resumable_upload_rest, +): + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + request_body = '{"name": "chunk_override.txt"}' + data = b"C" * 2048 + stream = io.BytesIO(data) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "chunk_granularity"), + ("Content-Type", "application/json"), + ] + + # Session 1: 256-byte chunks + config1 = ResumableUploadConfig( + chunk_size=256, + headers=scenario_headers, + ) + session1 = ResumableUploadSession( + upload_url=initial_url, + config=config1, + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + session1._initiate( + transport=client.transport._session, + request_body=request_body, + size=len(data), + ) + saved_url = session1.upload_url + + # Transmit 256 bytes + session1._transmit_chunk(client.transport._session, stream, len(data)) + assert session1.bytes_uploaded == 256 + + # Session 2: Resumes overriding chunk_size to 512 (valid multiple of 256) + config2 = ResumableUploadConfig( + chunk_size=512, + ) + session2 = ResumableUploadSession( + config=config2, + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + + stream.seek(0) + response = session2.resume( + upload_url=saved_url, + stream=stream, + chunk_size=512, + transport=client.transport._session, + ) + + assert session2.chunk_size == 512 + assert isinstance(response, UploadMediaResponse) + assert response.name == "chunk_override.txt" + assert response.size == len(data) + + +def test_resumable_upload_resume_helper_with_raw_bytes( + intercepted_resumable_upload_rest, +): + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + request_body = '{"name": "helper_bytes.bin"}' + data = b"B" * 1024 + + scenario_headers = [ + ("X-Goog-Test-Scenario", "chunk_granularity"), + ("Content-Type", "application/json"), + ] + + # Session 1: Start and upload first chunk + session1 = ResumableUploadSession( + upload_url=initial_url, + config=ResumableUploadConfig( + chunk_size=256, + headers=scenario_headers, + ), + transport=client.transport._session, + ) + session1._initiate( + transport=client.transport._session, + request_body=request_body, + size=len(data), + ) + saved_url = session1.upload_url + + stream1 = io.BytesIO(data) + session1._transmit_chunk(client.transport._session, stream1, len(data)) + assert session1.bytes_uploaded == 256 + + # Resume directly using resume_resumable_upload helper with raw bytes + config2 = ResumableUploadConfig( + chunk_size=512, + ) + final_response = resume_resumable_upload( + transport=client.transport._session, + upload_url=saved_url, + stream=data, + config=config2, + response_type=UploadMediaResponse, + ) + + assert isinstance(final_response, UploadMediaResponse) + assert final_response.name == "helper_bytes.bin" + assert final_response.size == len(data) + + +def test_resume_in_progress_upload(intercepted_resumable_upload_rest): + """2.5 Resumption Suite - Case 1: Resume an in-progress multi-chunk upload.""" + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + request_body = '{"name": "resume_in_progress.mp4"}' + total_size = 1_500_000 + chunk_size = 524_288 # 512 KiB + data = b"P" * total_size + stream = io.BytesIO(data) + + session1 = ResumableUploadSession( + upload_url=initial_url, + config=ResumableUploadConfig( + chunk_size=chunk_size, + headers={"Content-Type": "application/json"}, + ), + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + session1._initiate( + transport=client.transport._session, + request_body=request_body, + size=total_size, + ) + saved_url = session1.upload_url + assert saved_url is not None + + # Upload only the first 512 KiB chunk + session1._transmit_chunk(client.transport._session, stream, total_size) + assert session1.bytes_uploaded == 524_288 + assert not session1.finished + + # Resume from a fresh session with a rewound stream + stream.seek(0) + session2 = ResumableUploadSession( + config=ResumableUploadConfig(chunk_size=chunk_size), + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + snapshots = list( + session2.iter_resume( + upload_url=saved_url, + stream=stream, + size=total_size, + chunk_size=chunk_size, + transport=client.transport._session, + ) + ) + + assert [(p.state, p.bytes_uploaded) for p in snapshots] == [ + (ProgressState.OFFSET_RECEIVED, 524_288), + (ProgressState.UPLOADING, 1_048_576), + (ProgressState.FINALIZED, 1_500_000), + ] + assert isinstance(session2.response, UploadMediaResponse) + assert session2.response.name == "resume_in_progress.mp4" + assert session2.response.size == total_size + + +def test_resume_finalized_upload(intercepted_resumable_upload_rest): + """2.5 Resumption Suite - Case 2: Querying/recovering an already-finalized upload.""" + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + request_body = '{"name": "already_finalized.txt"}' + total_size = 524_288 + data = b"F" * total_size + + # Complete the upload in Session 1 + session1 = ResumableUploadSession( + upload_url=initial_url, + config=ResumableUploadConfig( + chunk_size=total_size, + headers={"Content-Type": "application/json"}, + ), + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + resp1 = session1.upload( + stream=io.BytesIO(data), + request_body=request_body, + size=total_size, + ) + assert isinstance(resp1, UploadMediaResponse) + assert resp1.size == total_size + saved_url = session1.upload_url + assert saved_url is not None + + # Session 2 recovers against the already-finalized session URL + session2 = ResumableUploadSession( + config=ResumableUploadConfig(chunk_size=total_size), + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + session2._state._resumable_url = saved_url + session2._state._upload_url = saved_url + session2._needs_recovery = True + progress_queue = [] + snapshots = list( + session2._transmit_all_chunks( + client.transport._session, + io.BytesIO(data), + total_size, + progress_queue=progress_queue, + ) + ) + + assert session2.finished + assert [p.state for p in snapshots] == [ + ProgressState.RECOVERING, + ProgressState.OFFSET_RECEIVED, + ] + assert isinstance(session2.response, UploadMediaResponse) + assert session2.response.name + assert session2.response.size == total_size + + +def test_resume_unseekable_stream_raises(intercepted_resumable_upload_rest): + """2.5 Resumption Suite - Case 5: Resuming with an unseekable stream raises UnseekableStreamError.""" + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + request_body = '{"name": "unseekable_resume.bin"}' + total_size = 1_048_576 + chunk_size = 524_288 + data = b"U" * total_size + stream1 = io.BytesIO(data) + + # Session 1 uploads first chunk of 512 KiB + session1 = ResumableUploadSession( + upload_url=initial_url, + config=ResumableUploadConfig( + chunk_size=chunk_size, + headers={"Content-Type": "application/json"}, + ), + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + session1._initiate( + transport=client.transport._session, + request_body=request_body, + size=total_size, + ) + saved_url = session1.upload_url + session1._transmit_chunk(client.transport._session, stream1, total_size) + assert session1.bytes_uploaded == chunk_size + + # Attempting to resume from offset 524,288 with an unseekable stream must raise UnseekableStreamError + unseekable_stream = _UnseekableBytesStream(data) + session2 = ResumableUploadSession( + config=ResumableUploadConfig(chunk_size=chunk_size), + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + with pytest.raises(exceptions.UnseekableStreamError) as exc_info: + session2.resume( + upload_url=saved_url, + stream=unseekable_stream, + size=total_size, + chunk_size=chunk_size, + transport=client.transport._session, + ) + + assert exc_info.value.upload_url == saved_url + assert exc_info.value.chunk_size == chunk_size + + +def test_golden_user_style_resume_seekable(intercepted_resumable_upload_rest): + """Test Case 3 / 2.5 Case 6: Mid-stream client interruption and resumption with a seekable stream.""" + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + request_body = '{"name": "golden_user_resume.mp4"}' + total_size = 1_500_000 + chunk_size = 524_288 # 512 KiB + data = b"G" * total_size + stream = io.BytesIO(data) + + class _ClientPauseError(Exception): + pass + + # Phase 1: Start Session 1 and intentionally abort after the first 512 KiB chunk + session1 = ResumableUploadSession( + upload_url=initial_url, + config=ResumableUploadConfig( + chunk_size=chunk_size, + headers={"Content-Type": "application/json"}, + ), + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + session1_snapshots = [] + with pytest.raises(_ClientPauseError): + for progress in session1.iter_upload( + stream=stream, + request_body=request_body, + size=total_size, + ): + session1_snapshots.append(progress) + if progress.bytes_uploaded >= chunk_size: + raise _ClientPauseError("Simulated user pause after first chunk") + + # Phase 2: State verification + saved_url = session1.upload_url + saved_chunk_size = session1.chunk_size + assert saved_url is not None + assert saved_chunk_size == chunk_size + assert [(p.state, p.bytes_uploaded) for p in session1_snapshots] == [ + (ProgressState.STARTED, 0), + (ProgressState.UPLOADING, 524_288), + ] + + # Phase 3: Rewind stream to byte 0 and resume in Session 2 + stream.seek(0) + session2 = ResumableUploadSession( + config=ResumableUploadConfig(chunk_size=saved_chunk_size), + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + session2_snapshots = list( + session2.iter_resume( + upload_url=saved_url, + stream=stream, + size=total_size, + chunk_size=saved_chunk_size, + transport=client.transport._session, + ) + ) + + assert [(p.state, p.bytes_uploaded) for p in session2_snapshots] == [ + (ProgressState.OFFSET_RECEIVED, 524_288), + (ProgressState.UPLOADING, 1_048_576), + (ProgressState.FINALIZED, 1_500_000), + ] + assert session2.finished + assert isinstance(session2.response, UploadMediaResponse) + assert session2.response.name == "golden_user_resume.mp4" + assert session2.response.size == total_size + + +if os.environ.get("GAPIC_PYTHON_ASYNC", "true") == "true": + + @pytest.mark.asyncio + async def test_async_client_upload_media_resume_direct( + intercepted_resumable_upload_rest_async, + ): + """Verify direct async resumption via ResumableUploadServiceAsyncClient.""" + client, _ = intercepted_resumable_upload_rest_async + total_size = 1_500_000 + chunk_size = 524_288 # 512 KiB + data = b"R" * total_size + stream = io.BytesIO(data) + + class _ClientPauseError(Exception): + pass + + session1 = await client.upload_media( + request=UploadMediaRequest(name="async_resume_direct.txt"), + config=ResumableUploadConfig(chunk_size=chunk_size), + ) + with pytest.raises(_ClientPauseError): + async for progress in session1.upload(stream=stream, size=total_size): + if progress.bytes_uploaded >= chunk_size: + raise _ClientPauseError("Simulated user pause after first chunk") + + saved_url = session1.upload_url + saved_chunk_size = session1.chunk_size + assert saved_url is not None + assert saved_chunk_size == chunk_size + + stream.seek(0) + session2 = await client.upload_media( + request=UploadMediaRequest(name="async_resume_direct.txt"), + config=ResumableUploadConfig(chunk_size=saved_chunk_size), + ) + response = await session2.resume( + upload_url=saved_url, + stream=stream, + size=total_size, + chunk_size=saved_chunk_size, + ) + + assert session2.finished + assert isinstance(response, UploadMediaResponse) + assert response.name == "async_resume_direct.txt" + assert response.size == total_size + + @pytest.mark.asyncio + async def test_async_client_upload_media_resume_seekable( + intercepted_resumable_upload_rest_async, + ): + """Verify async progress iteration during resumption via ResumableUploadServiceAsyncClient.""" + client, _ = intercepted_resumable_upload_rest_async + total_size = 1_500_000 + chunk_size = 524_288 # 512 KiB + data = b"G" * total_size + stream = io.BytesIO(data) + + class _ClientPauseError(Exception): + pass + + session1 = await client.upload_media( + request=UploadMediaRequest(name="async_golden_user_resume.mp4"), + config=ResumableUploadConfig(chunk_size=chunk_size), + ) + session1_snapshots = [] + with pytest.raises(_ClientPauseError): + async for progress in session1.upload(stream=stream, size=total_size): + session1_snapshots.append(progress) + if progress.bytes_uploaded >= chunk_size: + raise _ClientPauseError("Simulated user pause after first chunk") + + saved_url = session1.upload_url + saved_chunk_size = session1.chunk_size + assert saved_url is not None + assert saved_chunk_size == chunk_size + assert [(p.state, p.bytes_uploaded) for p in session1_snapshots] == [ + (ProgressState.STARTED, 0), + (ProgressState.UPLOADING, 524_288), + ] + + stream.seek(0) + session2 = await client.upload_media( + request=UploadMediaRequest(name="async_golden_user_resume.mp4"), + config=ResumableUploadConfig(chunk_size=saved_chunk_size), + ) + session2_snapshots = [ + p + async for p in session2.resume( + upload_url=saved_url, + stream=stream, + size=total_size, + chunk_size=saved_chunk_size, + ) + ] + + assert [(p.state, p.bytes_uploaded) for p in session2_snapshots] == [ + (ProgressState.OFFSET_RECEIVED, 524_288), + (ProgressState.UPLOADING, 1_048_576), + (ProgressState.FINALIZED, 1_500_000), + ] + assert session2.finished + assert isinstance(session2.response, UploadMediaResponse) + assert session2.response.name == "async_golden_user_resume.mp4" + assert session2.response.size == total_size + + +# ============================================================================= +# End-to-End Client Sample Patterns (Options 3 & 4) & 2.5 Resumption Suite +# ============================================================================= + + +@pytest.mark.parametrize("transport", ["grpc", "rest"]) +def test_sample_option_3_direct_resume(monkeypatch, transport): + """Option 3: Resume an interrupted upload directly across gRPC and REST transports.""" + client = _create_sync_client(monkeypatch, transport) + chunk_size = 524_288 # 512 KiB + total_size = 1_500_000 + payload = b"R" * total_size + stream = io.BytesIO(payload) + + request = UploadMediaRequest(name="sample_option_3_sync.mp4") + config = ResumableUploadConfig(chunk_size=chunk_size) + + session1 = client.upload_media(request=request, config=config) + with pytest.raises(_ClientPauseError) as exc_info: + for progress in session1.iter_upload(stream): + if ( + progress.state == ProgressState.UPLOADING + and progress.bytes_uploaded >= chunk_size + ): + raise _ClientPauseError( + "Simulated pause after first chunk", + upload_url=progress.upload_url, + chunk_size=session1.chunk_size, + ) + + saved_url = exc_info.value.upload_url + saved_chunk_size = exc_info.value.chunk_size + assert saved_url + assert saved_chunk_size == chunk_size + + # Create a fresh client and session to resume the interrupted upload + resume_client = _create_sync_client(monkeypatch, transport) + stream.seek(0) + upload_session = resume_client.upload_media(request=request) + response = upload_session.resume( + saved_url, stream, chunk_size=saved_chunk_size + ) + + assert isinstance(response, UploadMediaResponse) + assert response.name == "sample_option_3_sync.mp4" + assert response.size == total_size + + +@pytest.mark.parametrize("transport", ["grpc", "rest"]) +def test_sample_option_4_progress_tracking_resume(monkeypatch, transport): + """Option 4: Resume an interrupted upload while receiving progress updates.""" + client = _create_sync_client(monkeypatch, transport) + chunk_size = 524_288 # 512 KiB + total_size = 1_500_000 + payload = b"G" * total_size + stream = io.BytesIO(payload) + + request = UploadMediaRequest(name="sample_option_4_sync.mp4") + config = ResumableUploadConfig(chunk_size=chunk_size) + + session1 = client.upload_media(request=request, config=config) + with pytest.raises(_ClientPauseError) as exc_info: + for progress in session1.iter_upload(stream): + if ( + progress.state == ProgressState.UPLOADING + and progress.bytes_uploaded >= chunk_size + ): + raise _ClientPauseError( + "Simulated pause after first chunk", + upload_url=progress.upload_url, + chunk_size=session1.chunk_size, + ) + + saved_url = exc_info.value.upload_url + saved_chunk_size = exc_info.value.chunk_size + + resume_client = _create_sync_client(monkeypatch, transport) + stream.seek(0) + upload_session = resume_client.upload_media(request=request) + resume_progress: List[UploadProgress] = [] + for progress in upload_session.iter_resume( + saved_url, stream, chunk_size=saved_chunk_size + ): + resume_progress.append(progress) + + response = upload_session.response + assert isinstance(response, UploadMediaResponse) + assert response.name == "sample_option_4_sync.mp4" + assert response.size == total_size + assert [(p.state, p.bytes_uploaded) for p in resume_progress] == [ + (ProgressState.OFFSET_RECEIVED, 524_288), + (ProgressState.UPLOADING, 1_048_576), + (ProgressState.FINALIZED, total_size), + ] + + +@pytest.mark.parametrize("transport", ["grpc", "rest"]) +def test_client_resume_unseekable_stream_raises(monkeypatch, transport): + """Resumption Suite Case 5: Resuming with an unseekable stream raises UnseekableStreamError.""" + client = _create_sync_client(monkeypatch, transport) + total_size = 1_048_576 + chunk_size = 524_288 + data = b"U" * total_size + stream1 = io.BytesIO(data) + request = UploadMediaRequest(name="unseekable_resume.bin") + + session1 = client.upload_media( + request=request, + config=ResumableUploadConfig(chunk_size=chunk_size), + ) + with pytest.raises(_ClientPauseError) as pause_exc: + for progress in session1.iter_upload(stream1, size=total_size): + if ( + progress.state == ProgressState.UPLOADING + and progress.bytes_uploaded >= chunk_size + ): + raise _ClientPauseError( + "Pause after first chunk", + upload_url=progress.upload_url, + chunk_size=session1.chunk_size, + ) + + saved_url = pause_exc.value.upload_url + unseekable_stream = _UnseekableBytesStream(data) + session2 = client.upload_media(request=request) + with pytest.raises(exceptions.UnseekableStreamError) as exc_info: + session2.resume( + upload_url=saved_url, + stream=unseekable_stream, + size=total_size, + chunk_size=chunk_size, + ) + + assert exc_info.value.upload_url == saved_url + assert exc_info.value.chunk_size == chunk_size + + +if os.environ.get("GAPIC_PYTHON_ASYNC", "true") == "true": + + def _create_async_client( + monkeypatch, transport: str + ) -> ResumableUploadServiceAsyncClient: + # Disable local machine mTLS auto-detection for showcase test endpoints. + monkeypatch.setenv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "false") + if not HAS_ASYNC_REST_RESUMABLE_UPLOAD_TRANSPORT: + pytest.skip("Async REST transport not available.") + if ( + transport == "rest_asyncio" + and "rest_asyncio" not in ResumableUploadServiceClient._transport_registry + ): + pytest.skip( + "rest_asyncio transport is not registered when rest_async_io_enabled is False." + ) + return ResumableUploadServiceAsyncClient( + credentials=async_anonymous_credentials(), + client_options={"api_endpoint": SHOWCASE_API_ENDPOINT}, + transport=transport, + ) + + @pytest.mark.asyncio + @pytest.mark.parametrize("transport", ["grpc_asyncio", "rest_asyncio"]) + async def test_async_sample_option_3_direct_resume(monkeypatch, transport): + """Async Option 3: Resume an interrupted upload directly.""" + client = _create_async_client(monkeypatch, transport) + chunk_size = 524_288 # 512 KiB + total_size = 1_500_000 + payload = b"R" * total_size + stream = io.BytesIO(payload) + + request = UploadMediaRequest(name="sample_option_3_async.mp4") + config = ResumableUploadConfig(chunk_size=chunk_size) + + session1 = await client.upload_media(request=request, config=config) + with pytest.raises(_ClientPauseError) as exc_info: + async for progress in session1.upload(stream): + if ( + progress.state == ProgressState.UPLOADING + and progress.bytes_uploaded >= chunk_size + ): + raise _ClientPauseError( + "Simulated pause after first chunk", + upload_url=progress.upload_url, + chunk_size=session1.chunk_size, + ) + + saved_url = exc_info.value.upload_url + saved_chunk_size = exc_info.value.chunk_size + + resume_client = _create_async_client(monkeypatch, transport) + stream.seek(0) + upload_session = await resume_client.upload_media(request=request) + response = await upload_session.resume( + saved_url, stream, chunk_size=saved_chunk_size + ) + + assert isinstance(response, UploadMediaResponse) + assert response.name == "sample_option_3_async.mp4" + assert response.size == total_size + + @pytest.mark.asyncio + @pytest.mark.parametrize("transport", ["grpc_asyncio", "rest_asyncio"]) + async def test_async_sample_option_4_progress_tracking_resume( + monkeypatch, transport + ): + """Async Option 4: Resume an interrupted upload while receiving progress updates.""" + client = _create_async_client(monkeypatch, transport) + chunk_size = 524_288 # 512 KiB + total_size = 1_500_000 + payload = b"G" * total_size + stream = io.BytesIO(payload) + + request = UploadMediaRequest(name="sample_option_4_async.mp4") + config = ResumableUploadConfig(chunk_size=chunk_size) + + session1 = await client.upload_media(request=request, config=config) + with pytest.raises(_ClientPauseError) as exc_info: + async for progress in session1.upload(stream): + if ( + progress.state == ProgressState.UPLOADING + and progress.bytes_uploaded >= chunk_size + ): + raise _ClientPauseError( + "Simulated pause after first chunk", + upload_url=progress.upload_url, + chunk_size=session1.chunk_size, + ) + + saved_url = exc_info.value.upload_url + saved_chunk_size = exc_info.value.chunk_size + + resume_client = _create_async_client(monkeypatch, transport) + stream.seek(0) + upload_session = await resume_client.upload_media(request=request) + resume_progress: List[UploadProgress] = [] + async for progress in upload_session.resume( + saved_url, stream, chunk_size=saved_chunk_size + ): + resume_progress.append(progress) + + response = upload_session.response + assert isinstance(response, UploadMediaResponse) + assert response.name == "sample_option_4_async.mp4" + assert response.size == total_size + assert [(p.state, p.bytes_uploaded) for p in resume_progress] == [ + (ProgressState.OFFSET_RECEIVED, 524_288), + (ProgressState.UPLOADING, 1_048_576), + (ProgressState.FINALIZED, total_size), + ] + + @pytest.mark.asyncio + @pytest.mark.parametrize("transport", ["grpc_asyncio", "rest_asyncio"]) + async def test_async_client_resume_unseekable_stream_raises( + monkeypatch, transport + ): + """Async Resumption Suite Case 5: Resuming with an unseekable stream raises UnseekableStreamError.""" + client = _create_async_client(monkeypatch, transport) + total_size = 1_048_576 + chunk_size = 524_288 + data = b"U" * total_size + stream1 = io.BytesIO(data) + request = UploadMediaRequest(name="async_unseekable_resume.bin") + + session1 = await client.upload_media( + request=request, + config=ResumableUploadConfig(chunk_size=chunk_size), + ) + with pytest.raises(_ClientPauseError) as pause_exc: + async for progress in session1.upload(stream1, size=total_size): + if ( + progress.state == ProgressState.UPLOADING + and progress.bytes_uploaded >= chunk_size + ): + raise _ClientPauseError( + "Pause after first chunk", + upload_url=progress.upload_url, + chunk_size=session1.chunk_size, + ) + + saved_url = pause_exc.value.upload_url + unseekable_stream = _UnseekableBytesStream(data) + session2 = await client.upload_media(request=request) + with pytest.raises(exceptions.UnseekableStreamError) as exc_info: + await session2.resume( + upload_url=saved_url, + stream=unseekable_stream, + size=total_size, + chunk_size=chunk_size, + ) + + assert exc_info.value.upload_url == saved_url + assert exc_info.value.chunk_size == chunk_size + diff --git a/packages/gapic-generator/tests/system/test_resumable_upload_scenarios.py b/packages/gapic-generator/tests/system/test_resumable_upload_scenarios.py new file mode 100644 index 000000000000..e258b712816e --- /dev/null +++ b/packages/gapic-generator/tests/system/test_resumable_upload_scenarios.py @@ -0,0 +1,1812 @@ +# 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 +# +# https://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. + +"""System tests for GAPIC Resumable Upload protocol scenarios against gapic-showcase. + +Covers both `ResumableUploadSession` and end-to-end `ResumableUploadServiceClient` / +`ResumableUploadServiceAsyncClient` across default `grpc`/`grpc_asyncio` and +`rest`/`rest_asyncio` transports: +- Section 2.2 Chunk Granularity Suite (`chunk_granularity`) +- Section 2.3 Error Recovery Suite (`non_fatal_error_on_chunk_upload`: Category 1 + transient retries, Category 2 protocol recovery at offset 0 / chunk 2 / + finalizing chunk, and repeated no-header failures until global deadline exceeded) +- Section 2.4 Error on Start Suite (`non_fatal_error_on_start` & + `fatal_error_on_start`: 503 retry, 400 rejection, retry exhaustion, fatal + 403/404 immediate failure, and sequential session isolation) +""" + +import datetime +import io +import json +import os +import time +from typing import List +import uuid +import pytest + +from google.auth import credentials as ga_credentials +from google.api_core import exceptions as core_exceptions +from google.api_core import retry as retries +from google.api_core.resumable_transfer import ( + ProgressState, + ResumableUploadConfig, + ResumableUploadSession, + UploadProgress, +) +from google.showcase import ( + ResumableUploadServiceClient, + UploadMediaRequest, + UploadMediaResponse, +) + +from conftest import ( + HAS_ASYNC_REST_RESUMABLE_UPLOAD_TRANSPORT, + make_resumable_upload, +) + +if os.environ.get("GAPIC_PYTHON_ASYNC", "true") == "true": + from conftest import async_anonymous_credentials + from google.showcase import ResumableUploadServiceAsyncClient + + +SHOWCASE_API_ENDPOINT = "http://localhost:7469" + +FAST_RETRY = retries.Retry(initial=0.05, maximum=0.2, multiplier=1.5, timeout=5.0) +FAST_STREAMING_RETRY = retries.StreamingRetry( + initial=0.05, maximum=0.2, multiplier=1.5, timeout=5.0 +) +FAST_ASYNC_RETRY = retries.AsyncRetry( + initial=0.05, maximum=0.2, multiplier=1.5, timeout=5.0 +) +FAST_ASYNC_STREAMING_RETRY = retries.AsyncStreamingRetry( + initial=0.05, maximum=0.2, multiplier=1.5, timeout=5.0 +) + + +def test_resumable_upload_scenario_non_fatal_start_error(intercepted_resumable_upload_rest): + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + request_body = '{"name": "retry_start_upload.txt"}' + stream = io.BytesIO(b"Hello world!") + + # Injects 503 error on start attempt, which gets automatically retried + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_start"), + ( + "X-Goog-Test-Scenario-Config", + json.dumps({"client_uuid": str(uuid.uuid4()), "error_code": 503, "failure_count": 1}), + ), + ] + + response = make_resumable_upload( + transport=client.transport._session, + request_body=request_body, + stream=stream, + upload_url=initial_url, + chunk_size=256, + headers=scenario_headers, + ) + assert isinstance(response, bytes) + final_response = UploadMediaResponse.from_json(response) + assert final_response.name == "retry_start_upload.txt" + assert final_response.size == len(stream.getvalue()) + + +def test_resumable_upload_scenario_fatal_start_error(intercepted_resumable_upload_rest): + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + request_body = '{"name": "fatal_start_upload.txt"}' + stream = io.BytesIO(b"Hello fatal error!") + + # Injects 403 Forbidden error on start attempt (Category 3 unretriable error) + scenario_headers = [ + ("X-Goog-Test-Scenario", "fatal_error_on_start"), + ("X-Goog-Test-Scenario-Config", '{"error_code":403}'), + ] + + with pytest.raises(core_exceptions.Forbidden): + make_resumable_upload( + transport=client.transport._session, + request_body=request_body, + stream=stream, + upload_url=initial_url, + headers=scenario_headers, + ) + + +@pytest.mark.skip(reason="https://github.com/googleapis/gapic-showcase/issues/1685") +def test_resumable_upload_scenario_missing_status_header_start_retry(intercepted_resumable_upload_rest): + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + request_body = '{"name": "missing_status_header_upload.txt"}' + stream = io.BytesIO(b"Retrying on missing status header!") + + # Intercept first start response and strip X-Goog-Upload-Status header to test Category 1 retry + original_send = client.transport._session.send + attempt_count = [0] + + def intercepting_send(request, **kwargs): + resp = original_send(request, **kwargs) + if request.headers.get("X-Goog-Upload-Command") == "start": + attempt_count[0] += 1 + if attempt_count[0] == 1: + # Strip X-Goog-Upload-Status on first attempt + resp.headers.pop("X-Goog-Upload-Status", None) + resp.headers.pop("x-goog-upload-status", None) + return resp + + client.transport._session.send = intercepting_send + try: + response = make_resumable_upload( + transport=client.transport._session, + request_body=request_body, + stream=stream, + upload_url=initial_url, + ) + assert isinstance(response, bytes) + assert attempt_count[0] >= 2 # Verified that start was retried upon missing status header + final_response = UploadMediaResponse.from_json(response) + assert final_response.name == "missing_status_header_upload.txt" + assert final_response.size == len(stream.getvalue()) + finally: + client.transport._session.send = original_send + + +def test_resumable_upload_scenario_non_fatal_chunk_error(intercepted_resumable_upload_rest): + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + request_body = '{"name": "recovered_chunk_upload.txt"}' + stream = io.BytesIO(b"A" * 1024) + + # Injects 503 error on first chunk attempt, which ResumableUploadSession recovers via in-memory buffer + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_chunk_upload"), + ("X-Goog-Test-Scenario-Config", '{"error_code":503,"failure_count":1,"after_offset":0}'), + ] + + response = make_resumable_upload( + transport=client.transport._session, + request_body=request_body, + stream=stream, + upload_url=initial_url, + chunk_size=512, + headers=scenario_headers, + ) + assert isinstance(response, bytes) + final_response = UploadMediaResponse.from_json(response) + assert final_response.name == "recovered_chunk_upload.txt" + assert final_response.size == len(stream.getvalue()) + + +def test_resumable_upload_partial_commit_recovery(intercepted_resumable_upload_rest): + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + request_body = '{"name": "partial_commit_upload.txt"}' + data = b"0123456789" * 50 + stream = io.BytesIO(data) + + # Injects 503 error after server commits only 100 bytes of the chunk + scenario_headers = [ + ("X-Goog-Test-Scenario", "partial_commit_on_chunk_upload"), + ("X-Goog-Test-Scenario-Config", '{"error_code":503,"failure_count":1,"after_offset":0,"partial_bytes":100}'), + ] + + response = make_resumable_upload( + transport=client.transport._session, + request_body=request_body, + stream=stream, + upload_url=initial_url, + chunk_size=256, + headers=scenario_headers, + ) + assert isinstance(response, bytes) + final_response = UploadMediaResponse.from_json(response) + assert final_response.name == "partial_commit_upload.txt" + assert final_response.size == len(data) + + +def test_resumable_upload_chunk_granularity_alignment(intercepted_resumable_upload_rest): + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + request_body = '{"name": "granularity_upload.txt"}' + data = b"X" * 1000 + stream = io.BytesIO(data) + + # Server enforces 256 byte chunk granularity + scenario_headers = [ + ("X-Goog-Test-Scenario", "chunk_granularity"), + ] + + response = make_resumable_upload( + transport=client.transport._session, + request_body=request_body, + stream=stream, + upload_url=initial_url, + chunk_size=300, # Request unaligned chunk size (300) -> state machine aligns up to 512 + headers=scenario_headers, + ) + assert isinstance(response, bytes) + final_response = UploadMediaResponse.from_json(response) + assert final_response.name == "granularity_upload.txt" + assert final_response.size == len(data) + + +def test_chunk_granularity_alignment_1mb(intercepted_resumable_upload_rest): + """Chunk Granularity Suite Case 1: 1 MB upload with unaligned chunk_size=300_000.""" + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + total_size = 1_000_000 + payload = b"G" * total_size + stream = io.BytesIO(payload) + + config = ResumableUploadConfig( + chunk_size=300_000, + headers=[ + ("X-Goog-Test-Scenario", "chunk_granularity"), + ("Content-Type", "application/json"), + ], + ) + session = ResumableUploadSession( + upload_url=initial_url, + config=config, + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + + progress_records = list( + session.iter_upload( + stream, + request_body='{"name": "granularity_1mb.bin"}', + size=total_size, + timeout=5.0, + ) + ) + + assert session.chunk_size == 300_032 + assert isinstance(session.response, UploadMediaResponse) + assert session.response.size == total_size + + phases = [p.state for p in progress_records] + offsets = [p.bytes_uploaded for p in progress_records] + assert phases == [ + ProgressState.STARTED, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.FINALIZED, + ] + assert offsets == [0, 300_032, 600_064, 900_096, 1_000_000] + + +def test_cat1_error_retried(intercepted_resumable_upload_rest): + """Error Recovery Suite Case 1: 503 transient error on chunk 1 at offset 0.""" + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + chunk_size = 262_144 + total_size = 3 * chunk_size # 786_432 + payload = b"C" * total_size + stream = io.BytesIO(payload) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_chunk_upload"), + ( + "X-Goog-Test-Scenario-Config", + '{"error_code":503,"failure_count":1,"after_offset":0}', + ), + ("Content-Type", "application/json"), + ] + config = ResumableUploadConfig(chunk_size=chunk_size, headers=scenario_headers) + session = ResumableUploadSession( + upload_url=initial_url, + config=config, + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + + progress_records = list( + session.iter_upload( + stream, + request_body='{"name": "cat1_503.bin"}', + size=total_size, + retry=FAST_STREAMING_RETRY, + ) + ) + + assert isinstance(session.response, UploadMediaResponse) + assert session.response.size == total_size + assert progress_records[-1].state == ProgressState.FINALIZED + assert progress_records[-1].bytes_uploaded == total_size + + +def test_simple_cat2_error_recovery(intercepted_resumable_upload_rest): + """Error Recovery Suite Case 2: Category 2 error recovery at offset 0.""" + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + chunk_size = 262_144 + total_size = 3 * chunk_size # 786_432 + payload = b"D" * total_size + stream = io.BytesIO(payload) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_chunk_upload"), + ( + "X-Goog-Test-Scenario-Config", + '{"error_code":412,"failure_count":1,"after_offset":0}', + ), + ("Content-Type", "application/json"), + ] + config = ResumableUploadConfig(chunk_size=chunk_size, headers=scenario_headers) + session = ResumableUploadSession( + upload_url=initial_url, + config=config, + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + + progress_records = list( + session.iter_upload( + stream, + request_body='{"name": "cat2_offset_0.bin"}', + size=total_size, + retry=FAST_STREAMING_RETRY, + ) + ) + + assert isinstance(session.response, UploadMediaResponse) + assert session.response.size == total_size + + phases = [p.state for p in progress_records] + offsets = [p.bytes_uploaded for p in progress_records] + assert phases == [ + ProgressState.STARTED, + ProgressState.RECOVERING, + ProgressState.OFFSET_RECEIVED, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.FINALIZED, + ] + assert offsets == [0, 0, 0, 262_144, 524_288, 786_432] + + +def test_two_consecutive_cat2_recoveries_on_chunk_2(intercepted_resumable_upload_rest): + """Error Recovery Suite Case 3: Two consecutive Category 2 recoveries on chunk 2.""" + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + chunk_size = 262_144 + total_size = 3 * chunk_size # 786_432 + payload = b"E" * total_size + stream = io.BytesIO(payload) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_chunk_upload"), + ( + "X-Goog-Test-Scenario-Config", + '{"error_code":412,"failure_count":2,"after_offset":262144}', + ), + ("Content-Type", "application/json"), + ] + config = ResumableUploadConfig(chunk_size=chunk_size, headers=scenario_headers) + session = ResumableUploadSession( + upload_url=initial_url, + config=config, + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + + progress_records = list( + session.iter_upload( + stream, + request_body='{"name": "cat2_two_on_chunk_2.bin"}', + size=total_size, + retry=FAST_STREAMING_RETRY, + ) + ) + + assert isinstance(session.response, UploadMediaResponse) + assert session.response.size == total_size + + phases = [p.state for p in progress_records] + offsets = [p.bytes_uploaded for p in progress_records] + assert phases == [ + ProgressState.STARTED, + ProgressState.UPLOADING, + ProgressState.RECOVERING, + ProgressState.OFFSET_RECEIVED, + ProgressState.RECOVERING, + ProgressState.OFFSET_RECEIVED, + ProgressState.UPLOADING, + ProgressState.FINALIZED, + ] + assert offsets == [0, 262_144, 262_144, 262_144, 262_144, 262_144, 524_288, 786_432] + + +def test_cat2_failure_on_finalizing_chunk(intercepted_resumable_upload_rest): + """Error Recovery Suite Case 4: Category 2 failure on the finalizing chunk.""" + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + chunk_size = 262_144 + total_size = 3 * chunk_size - 100 # 786_332 + payload = b"F" * total_size + stream = io.BytesIO(payload) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_chunk_upload"), + ( + "X-Goog-Test-Scenario-Config", + '{"error_code":412,"failure_count":1,"after_offset":524288}', + ), + ("Content-Type", "application/json"), + ] + config = ResumableUploadConfig(chunk_size=chunk_size, headers=scenario_headers) + session = ResumableUploadSession( + upload_url=initial_url, + config=config, + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + + progress_records = list( + session.iter_upload( + stream, + request_body='{"name": "cat2_finalizing_chunk.bin"}', + size=total_size, + retry=FAST_STREAMING_RETRY, + ) + ) + + assert isinstance(session.response, UploadMediaResponse) + assert session.response.size == total_size + + phases = [p.state for p in progress_records] + offsets = [p.bytes_uploaded for p in progress_records] + assert phases == [ + ProgressState.STARTED, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.RECOVERING, + ProgressState.OFFSET_RECEIVED, + ProgressState.FINALIZED, + ] + assert offsets == [0, 262_144, 524_288, 524_288, 524_288, 786_332] + + +def test_no_headers_failure_recovers_until_deadline_exceeded(intercepted_resumable_upload_rest): + """Error Recovery Suite Case 5: Repeated no-header failures until global deadline exceeded.""" + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + chunk_size = 262_144 + payload = b"T" * chunk_size + stream = io.BytesIO(payload) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_chunk_upload"), + ( + "X-Goog-Test-Scenario-Config", + '{"failure_count":0,"action_after_failures":"terminate"}', + ), + ("Content-Type", "application/json"), + ] + deadline = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=1.0) + config = ResumableUploadConfig( + chunk_size=chunk_size, + deadline=deadline, + headers=scenario_headers, + ) + session = ResumableUploadSession( + upload_url=initial_url, + config=config, + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + + progress_records = [] + with pytest.raises((core_exceptions.DeadlineExceeded, core_exceptions.RetryError)): + for progress in session.iter_upload( + stream, + request_body='{"name": "terminate_until_deadline.bin"}', + size=chunk_size, + retry=FAST_STREAMING_RETRY, + ): + progress_records.append(progress) + + recovering_count = sum(1 for p in progress_records if p.state == ProgressState.RECOVERING) + assert recovering_count >= 1 + + +def test_non_fatal_error_on_start_503(intercepted_resumable_upload_rest): + """Error on Start Suite Case 1: Single 503 on start with fast retry.""" + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + payload = b"s" * 100 + stream = io.BytesIO(payload) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_start"), + ( + "X-Goog-Test-Scenario-Config", + json.dumps( + {"client_uuid": str(uuid.uuid4()), "error_code": 503, "failure_count": 1} + ), + ), + ("Content-Type", "application/json"), + ] + config = ResumableUploadConfig(headers=scenario_headers) + session = ResumableUploadSession( + upload_url=initial_url, + config=config, + transport=client.transport._session, + response_type=UploadMediaResponse, + start_retry=FAST_RETRY, + ) + + progress_records = list( + session.iter_upload(stream, request_body='{"name": "start_503.bin"}', size=100) + ) + + assert isinstance(session.response, UploadMediaResponse) + assert session.response.size == 100 + phases = [p.state for p in progress_records] + assert phases.count(ProgressState.STARTED) == 1 + assert phases[-1] == ProgressState.FINALIZED + + +def test_upload_rejection_400_on_start(intercepted_resumable_upload_rest): + """Test Case 2: Protocol-level 400 Bad Request rejection during session initiation.""" + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + payload = b"r" * 100 + stream = io.BytesIO(payload) + + # Invalid JSON in X-Goog-Test-Scenario-Config or injected 400 on start + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_start"), + ( + "X-Goog-Test-Scenario-Config", + json.dumps( + {"client_uuid": str(uuid.uuid4()), "error_code": 400, "failure_count": 1} + ), + ), + ("Content-Type", "application/json"), + ] + config = ResumableUploadConfig(headers=scenario_headers) + session = ResumableUploadSession( + upload_url=initial_url, + config=config, + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + + with pytest.raises(core_exceptions.BadRequest) as exc_info: + session.upload(stream, request_body="{}", size=100) + + assert exc_info.value.code == 400 + + +def test_retry_exhaustion_on_start_times_out(intercepted_resumable_upload_rest): + """Error on Start Suite Case 3: Repeated 503 on start until session deadline expires.""" + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + payload = b"t" * 100 + stream = io.BytesIO(payload) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_start"), + ( + "X-Goog-Test-Scenario-Config", + json.dumps( + { + "client_uuid": str(uuid.uuid4()), + "error_code": 503, + "failure_count": 10_000, + } + ), + ), + ("Content-Type", "application/json"), + ] + deadline = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=2.0) + config = ResumableUploadConfig(headers=scenario_headers, deadline=deadline) + session = ResumableUploadSession( + upload_url=initial_url, + config=config, + transport=client.transport._session, + response_type=UploadMediaResponse, + start_retry=retries.Retry(initial=0.2, maximum=0.5, multiplier=1.5, timeout=2.0), + ) + + progress_records = [] + t_start = time.monotonic() + with pytest.raises((core_exceptions.GoogleAPICallError, core_exceptions.RetryError)): + for progress in session.iter_upload( + stream, request_body='{"name": "start_exhaustion.bin"}', size=100 + ): + progress_records.append(progress) + elapsed = time.monotonic() - t_start + + assert elapsed >= 1.5 + phases = [p.state for p in progress_records] + assert ProgressState.UPLOADING not in phases + + +@pytest.mark.parametrize( + "status_code,expected_exc", + [ + (403, core_exceptions.Forbidden), + (404, core_exceptions.NotFound), + ], +) +def test_fatal_error_on_start_raises_immediately( + intercepted_resumable_upload_rest, status_code, expected_exc +): + """Error on Start Suite Case 4: Fatal 403 and 404 on start fail immediately without retry.""" + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + payload = b"f" * 100 + stream = io.BytesIO(payload) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "fatal_error_on_start"), + ("X-Goog-Test-Scenario-Config", json.dumps({"error_code": status_code})), + ("Content-Type", "application/json"), + ] + config = ResumableUploadConfig(headers=scenario_headers) + session = ResumableUploadSession( + upload_url=initial_url, + config=config, + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + + progress_records = [] + t_start = time.monotonic() + with pytest.raises(expected_exc) as exc_info: + for progress in session.iter_upload( + stream, request_body='{"name": "fatal_start.bin"}', size=100 + ): + progress_records.append(progress) + elapsed = time.monotonic() - t_start + + assert exc_info.value.code == status_code + assert elapsed < 0.5 + phases = [p.state for p in progress_records] + assert ProgressState.UPLOADING not in phases + + +def test_sequential_runs_session_isolation(intercepted_resumable_upload_rest): + """Error on Start Suite Case 5: Sequential runs with distinct client_uuid values.""" + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + payload = b"i" * 100 + + for run_idx in range(2): + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_start"), + ( + "X-Goog-Test-Scenario-Config", + json.dumps( + { + "client_uuid": str(uuid.uuid4()), + "error_code": 503, + "failure_count": 1, + } + ), + ), + ("Content-Type", "application/json"), + ] + config = ResumableUploadConfig(headers=scenario_headers) + session = ResumableUploadSession( + upload_url=initial_url, + config=config, + transport=client.transport._session, + response_type=UploadMediaResponse, + start_retry=FAST_RETRY, + ) + resp = session.upload( + io.BytesIO(payload), + request_body=f'{{"name": "isolated_run_{run_idx}.bin"}}', + size=100, + ) + assert isinstance(resp, UploadMediaResponse) + assert resp.name == f"isolated_run_{run_idx}.bin" + assert resp.size == 100 + + +def test_non_fatal_error_on_query_recovery(intercepted_resumable_upload_rest): + """Query retry scenario: transient 503 on query during session recovery.""" + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + chunk_size = 262_144 + total_size = 2 * chunk_size # 524_288 + payload = b"Q" * total_size + stream = io.BytesIO(payload) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_query"), + ( + "X-Goog-Test-Scenario-Config", + json.dumps({"error_code": 503, "failure_count": 1}), + ), + ("Content-Type", "application/json"), + ] + config = ResumableUploadConfig(chunk_size=chunk_size, headers=scenario_headers) + session = ResumableUploadSession( + upload_url=initial_url, + config=config, + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + session._initiate( + transport=client.transport._session, + request_body='{"name": "query_retry.bin"}', + size=total_size, + ) + session._transmit_chunk(client.transport._session, stream, total_size) + assert session.bytes_uploaded == chunk_size + + # Trigger recovery during _transmit_all_chunks; first query returns 503, second succeeds + session._needs_recovery = True + progress_records = list( + session._transmit_all_chunks( + client.transport._session, + stream, + total_size, + retry=FAST_STREAMING_RETRY, + ) + ) + + assert isinstance(session.response, UploadMediaResponse) + assert session.response.name == "query_retry.bin" + assert session.response.size == total_size + phases = [p.state for p in progress_records] + assert ProgressState.RECOVERING in phases + assert ProgressState.OFFSET_RECEIVED in phases + assert phases[-1] == ProgressState.FINALIZED + + +def test_client_upload_media_passes_start_retry(intercepted_resumable_upload_rest): + """Verify that `retry` passed to `client.upload_media` is forwarded as `start_retry`.""" + client, _ = intercepted_resumable_upload_rest + payload = b"s" * 100 + stream = io.BytesIO(payload) + + # Injects a single 409 Conflict on start, which is not retried by the default + # start retry policy unless the custom `retry` passed to `client.upload_media` + # reaches `ResumableUploadSession(start_retry=...)`. + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_start"), + ( + "X-Goog-Test-Scenario-Config", + json.dumps( + {"client_uuid": str(uuid.uuid4()), "error_code": 409, "failure_count": 1} + ), + ), + ] + retried_errors = [] + custom_retry = retries.Retry( + predicate=retries.if_exception_type(core_exceptions.Conflict), + initial=0.05, + maximum=0.2, + multiplier=1.5, + timeout=5.0, + on_error=retried_errors.append, + ) + + session = client.upload_media( + request=UploadMediaRequest(name="client_start_retry.bin"), + config=ResumableUploadConfig(headers=scenario_headers), + retry=custom_retry, + ) + response = session.upload(stream=stream, size=len(payload)) + + assert len(retried_errors) == 1 + assert isinstance(retried_errors[0], core_exceptions.Conflict) + assert isinstance(response, UploadMediaResponse) + assert response.name == "client_start_retry.bin" + assert response.size == len(payload) + + +if os.environ.get("GAPIC_PYTHON_ASYNC", "true") == "true": + + @pytest.mark.asyncio + async def test_async_client_upload_media_passes_start_retry( + intercepted_resumable_upload_rest_async, + ): + """Verify that `retry` passed to `async_client.upload_media` is forwarded as `start_retry`.""" + client, _ = intercepted_resumable_upload_rest_async + payload = b"s" * 100 + stream = io.BytesIO(payload) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_start"), + ( + "X-Goog-Test-Scenario-Config", + json.dumps( + { + "client_uuid": str(uuid.uuid4()), + "error_code": 409, + "failure_count": 1, + } + ), + ), + ] + retried_errors = [] + custom_retry = retries.AsyncRetry( + predicate=retries.if_exception_type(core_exceptions.Conflict), + initial=0.05, + maximum=0.2, + multiplier=1.5, + timeout=5.0, + on_error=retried_errors.append, + ) + + session = await client.upload_media( + request=UploadMediaRequest(name="async_client_start_retry.bin"), + config=ResumableUploadConfig(headers=scenario_headers), + retry=custom_retry, + ) + response = await session.upload(stream=stream, size=len(payload)) + + assert len(retried_errors) == 1 + assert isinstance(retried_errors[0], core_exceptions.Conflict) + assert isinstance(response, UploadMediaResponse) + assert response.name == "async_client_start_retry.bin" + assert response.size == len(payload) + + +# ============================================================================= +# End-to-End Showcase Client Scenario Suites (gRPC & REST) +# ============================================================================= + + +def _create_sync_client(transport, monkeypatch): + monkeypatch.setenv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "false") + return ResumableUploadServiceClient( + credentials=ga_credentials.AnonymousCredentials(), + client_options={"api_endpoint": SHOWCASE_API_ENDPOINT}, + transport=transport, + ) + + +# ----------------------------------------------------------------------------- +# 2.2 Chunk Granularity Suite (End-to-End Client across gRPC & REST) +# ----------------------------------------------------------------------------- + + +@pytest.mark.parametrize("transport", ["grpc", "rest"]) +def test_client_chunk_granularity_alignment(transport, monkeypatch): + """Chunk Granularity Suite Case 1: 1 MB upload with unaligned chunk_size=300_000.""" + client = _create_sync_client(transport, monkeypatch) + total_size = 1_000_000 + stream = io.BytesIO(b"G" * total_size) + + upload_session = client.upload_media( + request=UploadMediaRequest(name="granularity_1mb.bin"), + config=ResumableUploadConfig( + chunk_size=300_000, + headers=[("X-Goog-Test-Scenario", "chunk_granularity")], + ), + ) + progress_records = list( + upload_session.iter_upload(stream, size=total_size, timeout=5.0) + ) + + assert upload_session.chunk_size == 300_032 + assert isinstance(upload_session.response, UploadMediaResponse) + assert upload_session.response.size == total_size + assert [p.state for p in progress_records] == [ + ProgressState.STARTED, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.FINALIZED, + ] + assert [p.bytes_uploaded for p in progress_records] == [ + 0, + 300_032, + 600_064, + 900_096, + 1_000_000, + ] + + +# ----------------------------------------------------------------------------- +# 2.3 Error Recovery Suite (End-to-End Client across gRPC & REST) +# ----------------------------------------------------------------------------- + + +@pytest.mark.parametrize("transport", ["grpc", "rest"]) +def test_client_cat1_error_retried_transparently(transport, monkeypatch): + """Error Recovery Suite Case 1: 503 transient error on chunk 1 at offset 0.""" + client = _create_sync_client(transport, monkeypatch) + chunk_size = 262_144 + total_size = 3 * chunk_size # 786_432 + stream = io.BytesIO(b"C" * total_size) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_chunk_upload"), + ( + "X-Goog-Test-Scenario-Config", + '{"error_code":503,"failure_count":1,"after_offset":0}', + ), + ] + upload_session = client.upload_media( + request=UploadMediaRequest(name="cat1_503.bin"), + config=ResumableUploadConfig( + chunk_size=chunk_size, headers=scenario_headers + ), + ) + progress_records = list( + upload_session.iter_upload( + stream, size=total_size, retry=FAST_STREAMING_RETRY + ) + ) + + assert isinstance(upload_session.response, UploadMediaResponse) + assert upload_session.response.size == total_size + assert progress_records[-1].state == ProgressState.FINALIZED + assert progress_records[-1].bytes_uploaded == total_size + + +@pytest.mark.parametrize("transport", ["grpc", "rest"]) +def test_client_simple_cat2_error_recovery(transport, monkeypatch): + """Error Recovery Suite Case 2: Category 2 error recovery at offset 0.""" + client = _create_sync_client(transport, monkeypatch) + chunk_size = 262_144 + total_size = 3 * chunk_size # 786_432 + stream = io.BytesIO(b"D" * total_size) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_chunk_upload"), + ( + "X-Goog-Test-Scenario-Config", + '{"error_code":412,"failure_count":1,"after_offset":0}', + ), + ] + upload_session = client.upload_media( + request=UploadMediaRequest(name="cat2_offset_0.bin"), + config=ResumableUploadConfig( + chunk_size=chunk_size, headers=scenario_headers + ), + ) + progress_records = list( + upload_session.iter_upload( + stream, size=total_size, retry=FAST_STREAMING_RETRY + ) + ) + + assert isinstance(upload_session.response, UploadMediaResponse) + assert upload_session.response.size == total_size + assert [p.state for p in progress_records] == [ + ProgressState.STARTED, + ProgressState.RECOVERING, + ProgressState.OFFSET_RECEIVED, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.FINALIZED, + ] + assert [p.bytes_uploaded for p in progress_records] == [ + 0, + 0, + 0, + 262_144, + 524_288, + 786_432, + ] + + +@pytest.mark.parametrize("transport", ["grpc", "rest"]) +def test_client_two_consecutive_cat2_recoveries_on_chunk_2( + transport, monkeypatch +): + """Error Recovery Suite Case 3: Two consecutive Category 2 recoveries on chunk 2.""" + client = _create_sync_client(transport, monkeypatch) + chunk_size = 262_144 + total_size = 3 * chunk_size # 786_432 + stream = io.BytesIO(b"E" * total_size) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_chunk_upload"), + ( + "X-Goog-Test-Scenario-Config", + '{"error_code":412,"failure_count":2,"after_offset":262144}', + ), + ] + upload_session = client.upload_media( + request=UploadMediaRequest(name="cat2_two_on_chunk_2.bin"), + config=ResumableUploadConfig( + chunk_size=chunk_size, headers=scenario_headers + ), + ) + progress_records = list( + upload_session.iter_upload( + stream, size=total_size, retry=FAST_STREAMING_RETRY + ) + ) + + assert isinstance(upload_session.response, UploadMediaResponse) + assert upload_session.response.size == total_size + assert [p.state for p in progress_records] == [ + ProgressState.STARTED, + ProgressState.UPLOADING, + ProgressState.RECOVERING, + ProgressState.OFFSET_RECEIVED, + ProgressState.RECOVERING, + ProgressState.OFFSET_RECEIVED, + ProgressState.UPLOADING, + ProgressState.FINALIZED, + ] + assert [p.bytes_uploaded for p in progress_records] == [ + 0, + 262_144, + 262_144, + 262_144, + 262_144, + 262_144, + 524_288, + 786_432, + ] + + +@pytest.mark.parametrize("transport", ["grpc", "rest"]) +def test_client_cat2_failure_on_finalizing_chunk(transport, monkeypatch): + """Error Recovery Suite Case 4: Category 2 failure on the finalizing chunk.""" + client = _create_sync_client(transport, monkeypatch) + chunk_size = 262_144 + total_size = 3 * chunk_size - 100 # 786_332 + stream = io.BytesIO(b"F" * total_size) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_chunk_upload"), + ( + "X-Goog-Test-Scenario-Config", + '{"error_code":412,"failure_count":1,"after_offset":524288}', + ), + ] + upload_session = client.upload_media( + request=UploadMediaRequest(name="cat2_finalizing_chunk.bin"), + config=ResumableUploadConfig( + chunk_size=chunk_size, headers=scenario_headers + ), + ) + progress_records = list( + upload_session.iter_upload( + stream, size=total_size, retry=FAST_STREAMING_RETRY + ) + ) + + assert isinstance(upload_session.response, UploadMediaResponse) + assert upload_session.response.size == total_size + assert [p.state for p in progress_records] == [ + ProgressState.STARTED, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.RECOVERING, + ProgressState.OFFSET_RECEIVED, + ProgressState.FINALIZED, + ] + assert [p.bytes_uploaded for p in progress_records] == [ + 0, + 262_144, + 524_288, + 524_288, + 524_288, + 786_332, + ] + + +@pytest.mark.parametrize("transport", ["grpc", "rest"]) +def test_client_no_headers_failure_recovers_until_deadline_exceeded( + transport, monkeypatch +): + """Error Recovery Suite Case 5: Repeated no-header failures until global deadline exceeded.""" + client = _create_sync_client(transport, monkeypatch) + chunk_size = 262_144 + stream = io.BytesIO(b"T" * chunk_size) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_chunk_upload"), + ( + "X-Goog-Test-Scenario-Config", + '{"failure_count":0,"action_after_failures":"terminate"}', + ), + ] + deadline = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta( + seconds=1.0 + ) + upload_session = client.upload_media( + request=UploadMediaRequest(name="terminate_until_deadline.bin"), + config=ResumableUploadConfig( + chunk_size=chunk_size, + deadline=deadline, + headers=scenario_headers, + ), + ) + + progress_records: List[UploadProgress] = [] + with pytest.raises( + (core_exceptions.DeadlineExceeded, core_exceptions.RetryError) + ): + for progress in upload_session.iter_upload( + stream, size=chunk_size, retry=FAST_STREAMING_RETRY + ): + progress_records.append(progress) + + recovering_count = sum( + 1 for p in progress_records if p.state == ProgressState.RECOVERING + ) + assert recovering_count >= 1 + + +# ----------------------------------------------------------------------------- +# 2.4 Error on Start Suite (End-to-End Client across gRPC & REST) +# ----------------------------------------------------------------------------- + + +@pytest.mark.parametrize("transport", ["grpc", "rest"]) +def test_client_non_fatal_error_on_start_503(transport, monkeypatch): + """Error on Start Suite Case 1: Single 503 on start with fast retry.""" + client = _create_sync_client(transport, monkeypatch) + stream = io.BytesIO(b"s" * 100) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_start"), + ( + "X-Goog-Test-Scenario-Config", + json.dumps( + { + "client_uuid": str(uuid.uuid4()), + "error_code": 503, + "failure_count": 1, + } + ), + ), + ] + upload_session = client.upload_media( + request=UploadMediaRequest(name="start_503.bin"), + config=ResumableUploadConfig(headers=scenario_headers), + retry=FAST_RETRY, + ) + progress_records = list(upload_session.iter_upload(stream, size=100)) + + assert isinstance(upload_session.response, UploadMediaResponse) + assert upload_session.response.size == 100 + phases = [p.state for p in progress_records] + assert phases.count(ProgressState.STARTED) == 1 + assert phases[-1] == ProgressState.FINALIZED + + +@pytest.mark.parametrize("transport", ["grpc", "rest"]) +def test_client_upload_rejection_400_on_start(transport, monkeypatch): + """Error on Start Suite Case 2: Protocol-level 400 Bad Request rejection on start.""" + client = _create_sync_client(transport, monkeypatch) + stream = io.BytesIO(b"r" * 100) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_start"), + ( + "X-Goog-Test-Scenario-Config", + json.dumps( + { + "client_uuid": str(uuid.uuid4()), + "error_code": 400, + "failure_count": 1, + } + ), + ), + ] + upload_session = client.upload_media( + request=UploadMediaRequest(name="reject_400.bin"), + config=ResumableUploadConfig(headers=scenario_headers), + ) + with pytest.raises(core_exceptions.BadRequest) as exc_info: + upload_session.upload(stream, size=100) + + assert exc_info.value.code == 400 + + +@pytest.mark.parametrize("transport", ["grpc", "rest"]) +def test_client_retry_exhaustion_on_start_times_out(transport, monkeypatch): + """Error on Start Suite Case 3: Repeated 503 on start until session deadline expires.""" + client = _create_sync_client(transport, monkeypatch) + stream = io.BytesIO(b"t" * 100) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_start"), + ( + "X-Goog-Test-Scenario-Config", + json.dumps( + { + "client_uuid": str(uuid.uuid4()), + "error_code": 503, + "failure_count": 10_000, + } + ), + ), + ] + deadline = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta( + seconds=2.0 + ) + upload_session = client.upload_media( + request=UploadMediaRequest(name="start_exhaustion.bin"), + config=ResumableUploadConfig( + headers=scenario_headers, deadline=deadline + ), + retry=retries.Retry( + initial=0.2, maximum=0.5, multiplier=1.5, timeout=2.0 + ), + ) + + progress_records: List[UploadProgress] = [] + t_start = time.monotonic() + with pytest.raises( + (core_exceptions.GoogleAPICallError, core_exceptions.RetryError) + ): + for progress in upload_session.iter_upload(stream, size=100): + progress_records.append(progress) + elapsed = time.monotonic() - t_start + + assert elapsed >= 1.5 + phases = [p.state for p in progress_records] + assert ProgressState.UPLOADING not in phases + + +@pytest.mark.parametrize("transport", ["grpc", "rest"]) +@pytest.mark.parametrize( + "status_code,expected_exc", + [ + (403, core_exceptions.Forbidden), + (404, core_exceptions.NotFound), + ], +) +def test_client_fatal_error_on_start_raises_immediately( + transport, status_code, expected_exc, monkeypatch +): + """Error on Start Suite Case 4: Fatal 403 and 404 on start fail immediately without retry.""" + client = _create_sync_client(transport, monkeypatch) + stream = io.BytesIO(b"f" * 100) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "fatal_error_on_start"), + ( + "X-Goog-Test-Scenario-Config", + json.dumps({"error_code": status_code}), + ), + ] + upload_session = client.upload_media( + request=UploadMediaRequest(name="fatal_start.bin"), + config=ResumableUploadConfig(headers=scenario_headers), + ) + + progress_records: List[UploadProgress] = [] + t_start = time.monotonic() + with pytest.raises(expected_exc) as exc_info: + for progress in upload_session.iter_upload(stream, size=100): + progress_records.append(progress) + elapsed = time.monotonic() - t_start + + assert exc_info.value.code == status_code + assert elapsed < 0.5 + phases = [p.state for p in progress_records] + assert ProgressState.UPLOADING not in phases + + +@pytest.mark.parametrize("transport", ["grpc", "rest"]) +def test_client_sequential_runs_session_isolation(transport, monkeypatch): + """Error on Start Suite Case 5: Sequential runs with distinct client_uuid values.""" + client = _create_sync_client(transport, monkeypatch) + payload = b"i" * 100 + + for run_idx in range(2): + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_start"), + ( + "X-Goog-Test-Scenario-Config", + json.dumps( + { + "client_uuid": str(uuid.uuid4()), + "error_code": 503, + "failure_count": 1, + } + ), + ), + ] + upload_session = client.upload_media( + request=UploadMediaRequest(name=f"isolated_run_{run_idx}.bin"), + config=ResumableUploadConfig(headers=scenario_headers), + retry=FAST_RETRY, + ) + resp = upload_session.upload(io.BytesIO(payload), size=100) + assert isinstance(resp, UploadMediaResponse) + assert resp.name == f"isolated_run_{run_idx}.bin" + assert resp.size == 100 + + +if os.environ.get("GAPIC_PYTHON_ASYNC", "true") == "true": + + def _create_async_client(transport, monkeypatch): + monkeypatch.setenv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "false") + if not HAS_ASYNC_REST_RESUMABLE_UPLOAD_TRANSPORT: + pytest.skip("Async REST transport not available.") + if ( + transport == "rest_asyncio" + and "rest_asyncio" + not in ResumableUploadServiceClient._transport_registry + ): + pytest.skip( + "rest_asyncio transport is not registered when rest_async_io_enabled is False." + ) + return ResumableUploadServiceAsyncClient( + credentials=async_anonymous_credentials(), + client_options={"api_endpoint": SHOWCASE_API_ENDPOINT}, + transport=transport, + ) + + @pytest.mark.asyncio + @pytest.mark.parametrize("transport", ["grpc_asyncio", "rest_asyncio"]) + async def test_async_chunk_granularity_alignment(transport, monkeypatch): + """Async Chunk Granularity Suite Case 1: 1 MB upload with unaligned chunk_size=300_000.""" + client = _create_async_client(transport, monkeypatch) + total_size = 1_000_000 + stream = io.BytesIO(b"G" * total_size) + + upload_session = await client.upload_media( + request=UploadMediaRequest(name="async_granularity_1mb.bin"), + config=ResumableUploadConfig( + chunk_size=300_000, + headers=[("X-Goog-Test-Scenario", "chunk_granularity")], + ), + ) + progress_records = [ + p + async for p in upload_session.upload( + stream, size=total_size, timeout=5.0 + ) + ] + + assert upload_session.chunk_size == 300_032 + assert isinstance(upload_session.response, UploadMediaResponse) + assert upload_session.response.size == total_size + assert [p.state for p in progress_records] == [ + ProgressState.STARTED, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.FINALIZED, + ] + assert [p.bytes_uploaded for p in progress_records] == [ + 0, + 300_032, + 600_064, + 900_096, + 1_000_000, + ] + + @pytest.mark.asyncio + @pytest.mark.parametrize("transport", ["grpc_asyncio", "rest_asyncio"]) + async def test_async_cat1_error_retried_transparently( + transport, monkeypatch + ): + """Async Error Recovery Suite Case 1: 503 transient error on chunk 1 at offset 0.""" + client = _create_async_client(transport, monkeypatch) + chunk_size = 262_144 + total_size = 3 * chunk_size + stream = io.BytesIO(b"C" * total_size) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_chunk_upload"), + ( + "X-Goog-Test-Scenario-Config", + '{"error_code":503,"failure_count":1,"after_offset":0}', + ), + ] + upload_session = await client.upload_media( + request=UploadMediaRequest(name="async_cat1_503.bin"), + config=ResumableUploadConfig( + chunk_size=chunk_size, headers=scenario_headers + ), + ) + progress_records = [ + p + async for p in upload_session.upload( + stream, size=total_size, retry=FAST_ASYNC_STREAMING_RETRY + ) + ] + + assert isinstance(upload_session.response, UploadMediaResponse) + assert upload_session.response.size == total_size + assert progress_records[-1].state == ProgressState.FINALIZED + assert progress_records[-1].bytes_uploaded == total_size + + @pytest.mark.asyncio + @pytest.mark.parametrize("transport", ["grpc_asyncio", "rest_asyncio"]) + async def test_async_simple_cat2_error_recovery(transport, monkeypatch): + """Async Error Recovery Suite Case 2: Category 2 error recovery at offset 0.""" + client = _create_async_client(transport, monkeypatch) + chunk_size = 262_144 + total_size = 3 * chunk_size + stream = io.BytesIO(b"D" * total_size) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_chunk_upload"), + ( + "X-Goog-Test-Scenario-Config", + '{"error_code":412,"failure_count":1,"after_offset":0}', + ), + ] + upload_session = await client.upload_media( + request=UploadMediaRequest(name="async_cat2_offset_0.bin"), + config=ResumableUploadConfig( + chunk_size=chunk_size, headers=scenario_headers + ), + ) + progress_records = [ + p + async for p in upload_session.upload( + stream, size=total_size, retry=FAST_ASYNC_STREAMING_RETRY + ) + ] + + assert isinstance(upload_session.response, UploadMediaResponse) + assert upload_session.response.size == total_size + assert [p.state for p in progress_records] == [ + ProgressState.STARTED, + ProgressState.RECOVERING, + ProgressState.OFFSET_RECEIVED, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.FINALIZED, + ] + assert [p.bytes_uploaded for p in progress_records] == [ + 0, + 0, + 0, + 262_144, + 524_288, + 786_432, + ] + + @pytest.mark.asyncio + @pytest.mark.parametrize("transport", ["grpc_asyncio", "rest_asyncio"]) + async def test_async_two_consecutive_cat2_recoveries_on_chunk_2( + transport, monkeypatch + ): + """Async Error Recovery Suite Case 3: Two consecutive Category 2 recoveries on chunk 2.""" + client = _create_async_client(transport, monkeypatch) + chunk_size = 262_144 + total_size = 3 * chunk_size + stream = io.BytesIO(b"E" * total_size) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_chunk_upload"), + ( + "X-Goog-Test-Scenario-Config", + '{"error_code":412,"failure_count":2,"after_offset":262144}', + ), + ] + upload_session = await client.upload_media( + request=UploadMediaRequest(name="async_cat2_two_on_chunk_2.bin"), + config=ResumableUploadConfig( + chunk_size=chunk_size, headers=scenario_headers + ), + ) + progress_records = [ + p + async for p in upload_session.upload( + stream, size=total_size, retry=FAST_ASYNC_STREAMING_RETRY + ) + ] + + assert isinstance(upload_session.response, UploadMediaResponse) + assert upload_session.response.size == total_size + assert [p.state for p in progress_records] == [ + ProgressState.STARTED, + ProgressState.UPLOADING, + ProgressState.RECOVERING, + ProgressState.OFFSET_RECEIVED, + ProgressState.RECOVERING, + ProgressState.OFFSET_RECEIVED, + ProgressState.UPLOADING, + ProgressState.FINALIZED, + ] + assert [p.bytes_uploaded for p in progress_records] == [ + 0, + 262_144, + 262_144, + 262_144, + 262_144, + 262_144, + 524_288, + 786_432, + ] + + @pytest.mark.asyncio + @pytest.mark.parametrize("transport", ["grpc_asyncio", "rest_asyncio"]) + async def test_async_cat2_failure_on_finalizing_chunk( + transport, monkeypatch + ): + """Async Error Recovery Suite Case 4: Category 2 failure on the finalizing chunk.""" + client = _create_async_client(transport, monkeypatch) + chunk_size = 262_144 + total_size = 3 * chunk_size - 100 + stream = io.BytesIO(b"F" * total_size) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_chunk_upload"), + ( + "X-Goog-Test-Scenario-Config", + '{"error_code":412,"failure_count":1,"after_offset":524288}', + ), + ] + upload_session = await client.upload_media( + request=UploadMediaRequest(name="async_cat2_finalizing_chunk.bin"), + config=ResumableUploadConfig( + chunk_size=chunk_size, headers=scenario_headers + ), + ) + progress_records = [ + p + async for p in upload_session.upload( + stream, size=total_size, retry=FAST_ASYNC_STREAMING_RETRY + ) + ] + + assert isinstance(upload_session.response, UploadMediaResponse) + assert upload_session.response.size == total_size + assert [p.state for p in progress_records] == [ + ProgressState.STARTED, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.RECOVERING, + ProgressState.OFFSET_RECEIVED, + ProgressState.FINALIZED, + ] + assert [p.bytes_uploaded for p in progress_records] == [ + 0, + 262_144, + 524_288, + 524_288, + 524_288, + 786_332, + ] + + @pytest.mark.asyncio + @pytest.mark.parametrize("transport", ["grpc_asyncio", "rest_asyncio"]) + async def test_async_no_headers_failure_recovers_until_deadline_exceeded( + transport, monkeypatch + ): + """Async Error Recovery Suite Case 5: Repeated no-header failures until global deadline exceeded.""" + client = _create_async_client(transport, monkeypatch) + chunk_size = 262_144 + stream = io.BytesIO(b"T" * chunk_size) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_chunk_upload"), + ( + "X-Goog-Test-Scenario-Config", + '{"failure_count":0,"action_after_failures":"terminate"}', + ), + ] + deadline = datetime.datetime.now( + datetime.timezone.utc + ) + datetime.timedelta(seconds=1.0) + upload_session = await client.upload_media( + request=UploadMediaRequest( + name="async_terminate_until_deadline.bin" + ), + config=ResumableUploadConfig( + chunk_size=chunk_size, + deadline=deadline, + headers=scenario_headers, + ), + ) + + progress_records: List[UploadProgress] = [] + with pytest.raises( + (core_exceptions.DeadlineExceeded, core_exceptions.RetryError) + ): + async for progress in upload_session.upload( + stream, size=chunk_size, retry=FAST_ASYNC_STREAMING_RETRY + ): + progress_records.append(progress) + + recovering_count = sum( + 1 for p in progress_records if p.state == ProgressState.RECOVERING + ) + assert recovering_count >= 1 + + @pytest.mark.asyncio + @pytest.mark.parametrize("transport", ["grpc_asyncio", "rest_asyncio"]) + async def test_async_non_fatal_error_on_start_503(transport, monkeypatch): + """Async Error on Start Suite Case 1: Single 503 on start with fast retry.""" + client = _create_async_client(transport, monkeypatch) + stream = io.BytesIO(b"s" * 100) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_start"), + ( + "X-Goog-Test-Scenario-Config", + json.dumps( + { + "client_uuid": str(uuid.uuid4()), + "error_code": 503, + "failure_count": 1, + } + ), + ), + ] + upload_session = await client.upload_media( + request=UploadMediaRequest(name="async_start_503.bin"), + config=ResumableUploadConfig(headers=scenario_headers), + retry=FAST_ASYNC_RETRY, + ) + progress_records = [ + p async for p in upload_session.upload(stream, size=100) + ] + + assert isinstance(upload_session.response, UploadMediaResponse) + assert upload_session.response.size == 100 + phases = [p.state for p in progress_records] + assert phases.count(ProgressState.STARTED) == 1 + assert phases[-1] == ProgressState.FINALIZED + + @pytest.mark.asyncio + @pytest.mark.parametrize("transport", ["grpc_asyncio", "rest_asyncio"]) + async def test_async_upload_rejection_400_on_start(transport, monkeypatch): + """Async Error on Start Suite Case 2: Protocol-level 400 Bad Request rejection on start.""" + client = _create_async_client(transport, monkeypatch) + stream = io.BytesIO(b"r" * 100) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_start"), + ( + "X-Goog-Test-Scenario-Config", + json.dumps( + { + "client_uuid": str(uuid.uuid4()), + "error_code": 400, + "failure_count": 1, + } + ), + ), + ] + upload_session = await client.upload_media( + request=UploadMediaRequest(name="async_reject_400.bin"), + config=ResumableUploadConfig(headers=scenario_headers), + ) + with pytest.raises(core_exceptions.BadRequest) as exc_info: + await upload_session.upload(stream, size=100) + + assert exc_info.value.code == 400 + + @pytest.mark.asyncio + @pytest.mark.parametrize("transport", ["grpc_asyncio", "rest_asyncio"]) + async def test_async_retry_exhaustion_on_start_times_out( + transport, monkeypatch + ): + """Async Error on Start Suite Case 3: Repeated 503 on start until session deadline expires.""" + client = _create_async_client(transport, monkeypatch) + stream = io.BytesIO(b"t" * 100) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_start"), + ( + "X-Goog-Test-Scenario-Config", + json.dumps( + { + "client_uuid": str(uuid.uuid4()), + "error_code": 503, + "failure_count": 10_000, + } + ), + ), + ] + deadline = datetime.datetime.now( + datetime.timezone.utc + ) + datetime.timedelta(seconds=2.0) + upload_session = await client.upload_media( + request=UploadMediaRequest(name="async_start_exhaustion.bin"), + config=ResumableUploadConfig( + headers=scenario_headers, deadline=deadline + ), + retry=retries.AsyncRetry( + initial=0.2, maximum=0.5, multiplier=1.5, timeout=2.0 + ), + ) + + progress_records: List[UploadProgress] = [] + t_start = time.monotonic() + with pytest.raises( + (core_exceptions.GoogleAPICallError, core_exceptions.RetryError) + ): + async for progress in upload_session.upload(stream, size=100): + progress_records.append(progress) + elapsed = time.monotonic() - t_start + + assert elapsed >= 1.5 + phases = [p.state for p in progress_records] + assert ProgressState.UPLOADING not in phases + + @pytest.mark.asyncio + @pytest.mark.parametrize("transport", ["grpc_asyncio", "rest_asyncio"]) + @pytest.mark.parametrize( + "status_code,expected_exc", + [ + (403, core_exceptions.Forbidden), + (404, core_exceptions.NotFound), + ], + ) + async def test_async_fatal_error_on_start_raises_immediately( + transport, status_code, expected_exc, monkeypatch + ): + """Async Error on Start Suite Case 4: Fatal 403 and 404 on start fail immediately.""" + client = _create_async_client(transport, monkeypatch) + stream = io.BytesIO(b"f" * 100) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "fatal_error_on_start"), + ( + "X-Goog-Test-Scenario-Config", + json.dumps({"error_code": status_code}), + ), + ] + upload_session = await client.upload_media( + request=UploadMediaRequest(name="async_fatal_start.bin"), + config=ResumableUploadConfig(headers=scenario_headers), + ) + + progress_records: List[UploadProgress] = [] + t_start = time.monotonic() + with pytest.raises(expected_exc) as exc_info: + async for progress in upload_session.upload(stream, size=100): + progress_records.append(progress) + elapsed = time.monotonic() - t_start + + assert exc_info.value.code == status_code + assert elapsed < 0.5 + phases = [p.state for p in progress_records] + assert ProgressState.UPLOADING not in phases + + @pytest.mark.asyncio + @pytest.mark.parametrize("transport", ["grpc_asyncio", "rest_asyncio"]) + async def test_async_sequential_runs_session_isolation( + transport, monkeypatch + ): + """Async Error on Start Suite Case 5: Sequential runs with distinct client_uuid values.""" + client = _create_async_client(transport, monkeypatch) + payload = b"i" * 100 + + for run_idx in range(2): + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_start"), + ( + "X-Goog-Test-Scenario-Config", + json.dumps( + { + "client_uuid": str(uuid.uuid4()), + "error_code": 503, + "failure_count": 1, + } + ), + ), + ] + upload_session = await client.upload_media( + request=UploadMediaRequest( + name=f"async_isolated_run_{run_idx}.bin" + ), + config=ResumableUploadConfig(headers=scenario_headers), + retry=FAST_ASYNC_RETRY, + ) + resp = await upload_session.upload(io.BytesIO(payload), size=100) + assert isinstance(resp, UploadMediaResponse) + assert resp.name == f"async_isolated_run_{run_idx}.bin" + assert resp.size == 100 diff --git a/packages/gapic-generator/tests/system/test_resumable_upload_stall.py b/packages/gapic-generator/tests/system/test_resumable_upload_stall.py new file mode 100644 index 000000000000..ee4e70ac44c5 --- /dev/null +++ b/packages/gapic-generator/tests/system/test_resumable_upload_stall.py @@ -0,0 +1,221 @@ +# 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 +# +# https://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 datetime +import io +import pytest + +from google.api_core import exceptions +from google.api_core.resumable_transfer import ( + ResumableUploadConfig, + TransferStalledError, +) +from google.showcase import UploadMediaResponse + + +from conftest import make_resumable_upload, resume_resumable_upload + + +def test_resumable_upload_stall_control_success(intercepted_resumable_upload_rest): + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + request_body = '{"name": "stall_control_success.txt"}' + data = b"S" * 1024 + stream = io.BytesIO(data) + + # Stall control: 100 bytes/s minimum rate, 5s timeout -> fast upload succeeds easily + config = ResumableUploadConfig( + chunk_size=512, + stall_minimum_rate=100.0, + stall_timeout=5.0, + ) + + response = make_resumable_upload( + transport=client.transport._session, + request_body=request_body, + stream=stream, + upload_url=initial_url, + config=config, + ) + assert isinstance(response, bytes) + final_response = UploadMediaResponse.from_json(response) + assert final_response.name == "stall_control_success.txt" + assert final_response.size == len(data) + + +def test_resumable_upload_stall_control_triggers_abort(intercepted_resumable_upload_rest): + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + request_body = '{"name": "stall_abort.txt"}' + data = b"S" * 2048 + stream = io.BytesIO(data) + + # Server injects 600ms delay per chunk; client requires high rate (10000000 bytes/s) with 0.5s stall timeout + scenario_headers = [ + ("X-Goog-Test-Scenario", "happy_path"), + ("X-Goog-Test-Scenario-Config", '{"delay_ms":600}'), + ] + + config = ResumableUploadConfig( + chunk_size=512, + stall_minimum_rate=10_000_000.0, # 10 MB/s minimum + stall_timeout=0.5, # Abort if lagging for > 500ms + headers=scenario_headers, + ) + + with pytest.raises(TransferStalledError) as exc_info: + make_resumable_upload( + transport=client.transport._session, + request_body=request_body, + stream=stream, + upload_url=initial_url, + config=config, + ) + + assert "Upload stalled" in str(exc_info.value) + + +def test_resumable_upload_overall_deadline_exceeded(intercepted_resumable_upload_rest): + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + request_body = '{"name": "deadline_exceeded.txt"}' + data = b"D" * 2048 + stream = io.BytesIO(data) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "happy_path"), + ("X-Goog-Test-Scenario-Config", '{"delay_ms":600}'), + ] + + # Deadline is 300ms from now; 600ms server chunk delay forces deadline expiration + deadline = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(milliseconds=300) + config = ResumableUploadConfig( + chunk_size=512, + deadline=deadline, + headers=scenario_headers, + ) + + with pytest.raises(exceptions.DeadlineExceeded) as exc_info: + make_resumable_upload( + transport=client.transport._session, + request_body=request_body, + stream=stream, + upload_url=initial_url, + config=config, + ) + + assert "deadline" in str(exc_info.value).lower() + + +def test_resumable_upload_stall_vs_deadline_conversion(intercepted_resumable_upload_rest): + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + + scenario_headers = [ + ("X-Goog-Test-Scenario", "happy_path"), + ("X-Goog-Test-Scenario-Config", '{"delay_ms":600}'), + ] + + # Case A: Stall occurs before deadline -> raises TransferStalledError + future_deadline = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=10.0) + config_stall = ResumableUploadConfig( + chunk_size=512, + stall_minimum_rate=10_000_000.0, + stall_timeout=0.4, + deadline=future_deadline, + headers=scenario_headers, + ) + with pytest.raises(TransferStalledError) as exc_stall: + make_resumable_upload( + transport=client.transport._session, + request_body='{"name": "stall_before_deadline.txt"}', + stream=io.BytesIO(b"A" * 1024), + upload_url=initial_url, + config=config_stall, + ) + assert "Upload stalled" in str(exc_stall.value) + + # Case B: Server delay causes overall deadline to expire -> raises DeadlineExceeded + near_deadline = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(milliseconds=300) + config_deadline = ResumableUploadConfig( + chunk_size=512, + stall_minimum_rate=10_000_000.0, + stall_timeout=0.4, + deadline=near_deadline, + headers=scenario_headers, + ) + with pytest.raises(exceptions.DeadlineExceeded) as exc_dead: + make_resumable_upload( + transport=client.transport._session, + request_body='{"name": "deadline_before_stall.txt"}', + stream=io.BytesIO(b"B" * 1024), + upload_url=initial_url, + config=config_deadline, + ) + assert "deadline" in str(exc_dead.value).lower() + + +def test_resumable_upload_deadline_timeout_and_resume( + intercepted_resumable_upload_rest, +): + """Timeout on chunk 2 via after_offset + delay_ms, followed by manual resumption via upload_url.""" + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + chunk_size = 262_144 + total_size = 3 * chunk_size + data = b"R" * total_size + + # Inject 600ms delay starting on chunk 2 (offset 262144) so chunk 1 commits before deadline + scenario_headers = [ + ("X-Goog-Test-Scenario", "happy_path"), + ( + "X-Goog-Test-Scenario-Config", + f'{{"delay_ms":600,"after_offset":{chunk_size}}}', + ), + ] + deadline = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta( + milliseconds=350 + ) + config1 = ResumableUploadConfig( + chunk_size=chunk_size, + deadline=deadline, + headers=scenario_headers, + ) + + with pytest.raises(exceptions.DeadlineExceeded) as exc_info: + make_resumable_upload( + transport=client.transport._session, + request_body='{"name": "timeout_then_resume.bin"}', + stream=io.BytesIO(data), + upload_url=initial_url, + config=config1, + response_type=UploadMediaResponse, + ) + + saved_url = getattr(exc_info.value, "upload_url", None) + saved_chunk_size = getattr(exc_info.value, "chunk_size", chunk_size) + assert saved_url is not None + + # Resume with a fresh session and no tight deadline + final_response = resume_resumable_upload( + transport=client.transport._session, + upload_url=saved_url, + stream=io.BytesIO(data), + config=ResumableUploadConfig(chunk_size=saved_chunk_size), + response_type=UploadMediaResponse, + ) + assert isinstance(final_response, UploadMediaResponse) + assert final_response.name == "timeout_then_resume.bin" + assert final_response.size == total_size + diff --git a/packages/gapic-generator/tests/system_live/README.md b/packages/gapic-generator/tests/system_live/README.md new file mode 100644 index 000000000000..bf36d1e8530e --- /dev/null +++ b/packages/gapic-generator/tests/system_live/README.md @@ -0,0 +1,41 @@ +# Live Google Ads Acceptance Tests (`tests/system_live/`) + +This directory contains live acceptance tests ([`test_google_ads_resumable_upload.py`](test_google_ads_resumable_upload.py)) that exercise end-to-end resumable uploads and mid-stream session resumptions against the generated Google Ads `YouTubeVideoUploadService` GAPIC client (`https://googleads.googleapis.com`) across `grpc`, `rest`, `grpc_asyncio`, and `rest_asyncio` transports. + +These tests are isolated in `tests/system_live/` (without `tests/system/conftest.py`) so Showcase system test runs (`tests/system/`) never depend on external credentials or live API endpoints. + +--- + +## 1. Configure Credentials and Environment Variables + +The acceptance suite uses two-tier service account impersonation (`google.auth.impersonated_credentials.Credentials`): + +1. **Source Service Account (`GOOGLE_APPLICATION_CREDENTIALS` / `GOOGLE_ADS_SOURCE_SERVICE_ACCOUNT`)**: + - Service account JSON key with `roles/iam.serviceAccountTokenCreator` permission on the target service account (and `iamcredentials.googleapis.com` enabled on its project). +2. **Target Service Account (`GOOGLE_ADS_TARGET_SERVICE_ACCOUNT`)**: + - Service account granted access to the Google Ads Manager / Customer account (`GOOGLE_ADS_LOGIN_CUSTOMER_ID` / `GOOGLE_ADS_CUSTOMER_ID`) with `googleads.googleapis.com` enabled on its project. +3. **Sample Video Payload (`GOOGLE_ADS_VIDEO_PATH`)**: + - Local `.mp4` file (defaults to `~/Downloads/video.mp4` if unset; should be > 512 KiB to exercise multi-chunk progress and resumption). + +Export the required environment variables in the active shell: + +```bash +export GOOGLE_APPLICATION_CREDENTIALS="/path/to/source-service-account-key.json" +export GOOGLE_ADS_SOURCE_SERVICE_ACCOUNT="@.iam.gserviceaccount.com" +export GOOGLE_ADS_TARGET_SERVICE_ACCOUNT="@.iam.gserviceaccount.com" +export GOOGLE_ADS_DEVELOPER_TOKEN="" +export GOOGLE_ADS_LOGIN_CUSTOMER_ID="" +export GOOGLE_ADS_CUSTOMER_ID="" +export GOOGLE_ADS_VIDEO_PATH="${HOME}/Downloads/video.mp4" +``` + +--- + +## 2. Run the Live Acceptance Tests + +From `packages/gapic-generator`, run the [`system`](../../noxfile.py) `nox` session (which sparse-clones `googleapis`, generates and installs the Google Ads GAPIC library via `protoc`, and runs `pytest` against `tests/system_live`): + +```bash +cd packages/gapic-generator +nox -s system-3.14 +``` diff --git a/packages/gapic-generator/tests/system_live/test_google_ads_resumable_upload.py b/packages/gapic-generator/tests/system_live/test_google_ads_resumable_upload.py new file mode 100644 index 000000000000..c97e67292cc4 --- /dev/null +++ b/packages/gapic-generator/tests/system_live/test_google_ads_resumable_upload.py @@ -0,0 +1,664 @@ +# 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 +# +# https://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. + +"""Live Google Ads acceptance tests for GAPIC Resumable Upload client libraries. + +Exercises `YouTubeVideoUploadServiceClient` and +`YouTubeVideoUploadServiceAsyncClient` (with `grpc`/`grpc_asyncio` and +`rest`/`rest_asyncio` transports) against `https://googleads.googleapis.com` +using two-tier service account impersonation. + +To run the live Google Ads acceptance suite: + GOOGLE_ADS_DEVELOPER_TOKEN="" \\ + GOOGLE_ADS_LOGIN_CUSTOMER_ID="" \\ + GOOGLE_ADS_CUSTOMER_ID="" \\ + GOOGLE_ADS_TARGET_SERVICE_ACCOUNT="@.iam.gserviceaccount.com" \\ + GOOGLE_ADS_SOURCE_SERVICE_ACCOUNT="@.iam.gserviceaccount.com" \\ + GOOGLE_ADS_VIDEO_PATH="~/Downloads/video.mp4" \\ + nox -s system-3.14 +""" + +from datetime import datetime, timedelta, timezone +import io +import os +from pathlib import Path +import re +from typing import List, Optional, Sequence, Tuple + +import pytest +import requests + +import google.auth +from google.auth import credentials as ga_credentials +from google.auth import impersonated_credentials +import google.auth.transport.requests +from google.oauth2 import service_account +from google.api_core import exceptions as core_exceptions +from google.api_core.resumable_transfer import ( + ProgressState, + ResumableUploadConfig, + UploadProgress, +) + + +class _ClientPauseError(Exception): + """Client-side exception raised inside progress loop to simulate user pause.""" + + def __init__(self, message: str, upload_url: str, chunk_size: int) -> None: + """Initialize the pause exception with resumable session state. + + Args: + message: Human-readable reason for pausing the upload. + upload_url: Resumable session URI used to resume the upload. + chunk_size: Active chunk size in bytes for the upload session. + """ + super().__init__(message) + self.upload_url = upload_url + self.chunk_size = chunk_size + + +# ============================================================================= +# Live Google Ads (YouTubeVideoUploadService) Acceptance Tests +# ============================================================================= + + +ADWORDS_SCOPE = "https://www.googleapis.com/auth/adwords" +SOURCE_SCOPES = ( + "https://www.googleapis.com/auth/iam", + "https://www.googleapis.com/auth/cloud-platform", +) +VIDEO_TITLE_PREFIX = "gapic-generator-resumable-upload-" +VIDEO_TITLE_TIMESTAMP_FMT = "%Y%m%dT%H%M%SZ" +STALE_UPLOAD_MAX_AGE = timedelta(hours=2) + +from google.ads.googleads_v23.services.services.you_tube_video_upload_service import ( + YouTubeVideoUploadServiceAsyncClient, + YouTubeVideoUploadServiceClient, +) + + +def _generate_video_title() -> str: + """Generate a test video title tagged with the suite prefix and UTC timestamp. + + Returns: + Formatted video title string containing `VIDEO_TITLE_PREFIX` and the + current UTC timestamp (`%Y%m%dT%H%M%SZ`). + """ + ts = datetime.now(timezone.utc).strftime(VIDEO_TITLE_TIMESTAMP_FMT) + return f"{VIDEO_TITLE_PREFIX}{ts}" + + +def _parse_video_title_timestamp(title: str) -> Optional[datetime]: + """Extract the UTC creation timestamp from a prefixed test video title. + + Args: + title: Video title string retrieved from YouTube metadata. + + Returns: + Timezone-aware UTC `datetime` if `title` starts with + `VIDEO_TITLE_PREFIX` and ends with a valid timestamp, otherwise `None`. + """ + if not title.startswith(VIDEO_TITLE_PREFIX): + return None + raw_ts = title[len(VIDEO_TITLE_PREFIX) :] + try: + return datetime.strptime(raw_ts, VIDEO_TITLE_TIMESTAMP_FMT).replace( + tzinfo=timezone.utc + ) + except ValueError: + return None + + +def _fetch_youtube_video_title(video_id: str) -> Optional[str]: + """Resolve the title of an uploaded YouTube video via the public oEmbed endpoint. + + Args: + video_id: 11-character YouTube video identifier (`you_tube_video_upload.video_id`). + + Returns: + Video title string if the oEmbed lookup succeeds with HTTP 200, + otherwise `None`. + """ + resp = requests.get( + "https://www.youtube.com/oembed", + params={ + "url": f"https://www.youtube.com/watch?v={video_id}", + "format": "json", + }, + timeout=10, + ) + if resp.status_code != 200: + return None + return resp.json().get("title") + + +def _load_source_credentials() -> ga_credentials.Credentials: + """Load default source credentials scoped for IAM impersonation and Cloud Platform. + + Returns: + Google Auth credentials object loaded via Application Default Credentials. + """ + creds, _ = google.auth.default(scopes=list(SOURCE_SCOPES)) + return creds + + +def _load_impersonated_credentials() -> impersonated_credentials.Credentials: + """Construct impersonated Google Ads credentials for the target service account. + + Returns: + Impersonated credentials configured for `ADWORDS_SCOPE` targeting + `GOOGLE_ADS_TARGET_SERVICE_ACCOUNT`. + """ + target_principal = os.environ.get("GOOGLE_ADS_TARGET_SERVICE_ACCOUNT", "") + if not target_principal: + pytest.fail("GOOGLE_ADS_TARGET_SERVICE_ACCOUNT is not set.") + source_creds = _load_source_credentials() + return impersonated_credentials.Credentials( + source_credentials=source_creds, + target_principal=target_principal, + target_scopes=[ADWORDS_SCOPE], + lifetime=3600, + ) + + +def _google_ads_metadata() -> Sequence[Tuple[str, str]]: + """Build gRPC/REST request metadata headers for Google Ads API calls. + + Returns: + Sequence of `(header_name, header_value)` pairs containing + `developer-token` and `login-customer-id`. + """ + developer_token = os.environ.get("GOOGLE_ADS_DEVELOPER_TOKEN", "") + login_customer_id = os.environ.get("GOOGLE_ADS_LOGIN_CUSTOMER_ID", "") + if not developer_token or not login_customer_id: + pytest.fail( + "GOOGLE_ADS_DEVELOPER_TOKEN and GOOGLE_ADS_LOGIN_CUSTOMER_ID are required." + ) + return ( + ("developer-token", developer_token), + ("login-customer-id", login_customer_id), + ) + + +def _google_ads_customer_id() -> str: + """Read the target Google Ads customer ID from environment configuration. + + Returns: + Customer ID string read from `GOOGLE_ADS_CUSTOMER_ID` (falling back to + `GOOGLE_ADS_LOGIN_CUSTOMER_ID`). + """ + customer_id = os.environ.get( + "GOOGLE_ADS_CUSTOMER_ID", + os.environ.get("GOOGLE_ADS_LOGIN_CUSTOMER_ID", ""), + ) + if not customer_id: + pytest.fail("GOOGLE_ADS_CUSTOMER_ID is required.") + return customer_id + + +def _open_upload_file() -> Tuple[io.BufferedReader, int]: + """Open the sample video payload file in binary mode and inspect its size. + + Returns: + Tuple of `(stream, upload_size)` where `stream` is an open binary file + reader and `upload_size` is the file size in bytes. + """ + raw_path = os.environ.get("GOOGLE_ADS_VIDEO_PATH", "~/Downloads/video.mp4") + expanded = Path(os.path.expanduser(raw_path)).resolve() + if not expanded.is_file(): + pytest.fail(f"Test media file not found at {expanded}") + f = open(expanded, "rb") + return f, expanded.stat().st_size + + +def _try_remove_upload( + client: YouTubeVideoUploadServiceClient, + customer_id: str, + resource_name: str, + metadata: Sequence[Tuple[str, str]], +) -> None: + """Attempt best-effort removal of a newly uploaded YouTube video resource. + + Immediately after upload completion (`ProgressState.FINALIZED`), the video + remains in `UPLOADED` state while YouTube processes the media stream, during + which `RemoveYouTubeVideoUpload` returns `INVALID_ARGUMENT` (`NOT_FOUND`). + Any upload still processing at teardown is removed by `_cleanup_stale_test_videos` + once processed and older than `STALE_UPLOAD_MAX_AGE`. + + Args: + client: Synchronous `YouTubeVideoUploadServiceClient` instance. + customer_id: Google Ads customer ID owning the upload resource. + resource_name: Resource name (`customers/{id}/youTubeVideoUploads/{id}`). + metadata: Request metadata headers (`developer-token`, `login-customer-id`). + """ + try: + client.remove_you_tube_video_upload( + request={ + "customer_id": customer_id, + "resource_names": [resource_name], + }, + metadata=metadata, + ) + except core_exceptions.GoogleAPICallError: + pass + + +async def _async_try_remove_upload( + client: YouTubeVideoUploadServiceAsyncClient, + customer_id: str, + resource_name: str, + metadata: Sequence[Tuple[str, str]], +) -> None: + """Attempt best-effort async removal of a newly uploaded YouTube video resource. + + Args: + client: Asynchronous `YouTubeVideoUploadServiceAsyncClient` instance. + customer_id: Google Ads customer ID owning the upload resource. + resource_name: Resource name (`customers/{id}/youTubeVideoUploads/{id}`). + metadata: Request metadata headers (`developer-token`, `login-customer-id`). + """ + try: + await client.remove_you_tube_video_upload( + request={ + "customer_id": customer_id, + "resource_names": [resource_name], + }, + metadata=metadata, + ) + except core_exceptions.GoogleAPICallError: + pass + + +@pytest.fixture(scope="session", autouse=True) +def _cleanup_stale_test_videos(): + """Query processed YouTube video uploads and remove test resources older than 2 hours. + + Queries `you_tube_video_upload` resources in `PROCESSED` state via + `googleAds:search`, inspects video titles via YouTube oEmbed to identify + test resources created with `VIDEO_TITLE_PREFIX`, and removes any resource + whose embedded timestamp exceeds `STALE_UPLOAD_MAX_AGE`. + """ + creds = _load_impersonated_credentials() + customer_id = _google_ads_customer_id() + metadata = _google_ads_metadata() + developer_token = dict(metadata)["developer-token"] + login_customer_id = dict(metadata)["login-customer-id"] + + creds.refresh(google.auth.transport.requests.Request()) + headers = { + "Authorization": f"Bearer {creds.token}", + "developer-token": developer_token, + "login-customer-id": login_customer_id, + } + search_url = ( + f"https://googleads.googleapis.com/v23/customers/{customer_id}/googleAds:search" + ) + resp = requests.post( + search_url, + headers=headers, + json={ + "query": ( + "SELECT you_tube_video_upload.resource_name, " + "you_tube_video_upload.video_id " + "FROM you_tube_video_upload " + "WHERE you_tube_video_upload.state = 'PROCESSED'" + ) + }, + timeout=30, + ) + if resp.status_code != 200: + return + + now = datetime.now(timezone.utc) + stale_resource_names: List[str] = [] + for row in resp.json().get("results", []): + upload = row.get("youTubeVideoUpload", {}) + video_id = upload.get("videoId") + resource_name = upload.get("resourceName") + if not video_id or not resource_name: + continue + title = _fetch_youtube_video_title(video_id) + if not title: + continue + created_at = _parse_video_title_timestamp(title) + if created_at is not None and (now - created_at) > STALE_UPLOAD_MAX_AGE: + stale_resource_names.append(resource_name) + + if stale_resource_names: + client = YouTubeVideoUploadServiceClient(credentials=creds) + for resource_name in stale_resource_names: + _try_remove_upload(client, customer_id, resource_name, metadata) + + +class TestGoogleAdsLiveAcceptance: + """Live acceptance tests against Google Ads YouTubeVideoUploadService.""" + + @pytest.fixture(autouse=True) + def _disable_mtls(self, monkeypatch): + """Disable mTLS client certificates for test execution. + + Args: + monkeypatch: Pytest `MonkeyPatch` fixture for setting environment variables. + """ + monkeypatch.setenv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "false") + + def test_adc_source_credentials_are_service_account(self): + """Verify ADC source credentials load as a service account matching configuration.""" + creds = _load_source_credentials() + assert isinstance(creds, service_account.Credentials) + expected_source_sa = os.environ.get("GOOGLE_ADS_SOURCE_SERVICE_ACCOUNT") + if expected_source_sa: + assert creds.service_account_email == expected_source_sa + + def test_impersonated_credentials_fetch_access_token(self): + """Verify target service account impersonation mints a valid bearer access token.""" + creds = _load_impersonated_credentials() + creds.refresh(google.auth.transport.requests.Request()) + headers = {} + creds.apply(headers) + + assert creds.token is not None + assert len(creds.token) > 0 + auth_header = headers.get("authorization") or headers.get("Authorization") + assert auth_header is not None + assert auth_header.startswith("Bearer ") + assert creds.expiry is not None + now_utc = datetime.now(timezone.utc).replace(tzinfo=None) + assert creds.expiry > now_utc + + @pytest.mark.parametrize("transport", ["grpc", "rest"]) + def test_upload_with_impersonated_credentials(self, transport: str): + """Verify complete synchronous resumable video upload and resource cleanup. + + Args: + transport: Client transport name (`"grpc"` or `"rest"`). + """ + client = YouTubeVideoUploadServiceClient( + credentials=_load_impersonated_credentials(), + transport=transport, + ) + customer_id = _google_ads_customer_id() + metadata = _google_ads_metadata() + stream, upload_size = _open_upload_file() + response = None + try: + upload_session = client.create_you_tube_video_upload( + request={ + "customer_id": customer_id, + "you_tube_video_upload": { + "video_title": _generate_video_title(), + "video_description": "Testing defaults", + "video_privacy": "UNLISTED", + }, + }, + metadata=metadata, + ) + progress_records: List[UploadProgress] = [] + for progress in upload_session.iter_upload( + stream, size=upload_size, content_type="video/mp4" + ): + progress_records.append(progress) + + response = upload_session.response + assert response is not None + assert re.match( + r"^customers/\d+/youTubeVideoUploads/\d+$", + response.resource_name, + ) + assert len(progress_records) >= 2 + assert progress_records[0].state == ProgressState.STARTED + assert progress_records[-1].state == ProgressState.FINALIZED + assert progress_records[-1].bytes_uploaded == upload_size + finally: + stream.close() + if response is not None and response.resource_name: + _try_remove_upload( + client, customer_id, response.resource_name, metadata + ) + + @pytest.mark.parametrize("transport", ["grpc", "rest"]) + def test_golden_user_style_resume_seekable(self, transport: str): + """Verify synchronous mid-stream interruption, session resumption, and cleanup. + + Args: + transport: Client transport name (`"grpc"` or `"rest"`). + """ + client = YouTubeVideoUploadServiceClient( + credentials=_load_impersonated_credentials(), + transport=transport, + ) + customer_id = _google_ads_customer_id() + metadata = _google_ads_metadata() + stream, upload_size = _open_upload_file() + chunk_size = 512 * 1024 # 512 KiB + request_payload = { + "customer_id": customer_id, + "you_tube_video_upload": { + "video_title": _generate_video_title(), + "video_description": "Testing defaults", + "video_privacy": "UNLISTED", + }, + } + response = None + try: + session1 = client.create_you_tube_video_upload( + request=request_payload, + config=ResumableUploadConfig(chunk_size=chunk_size), + metadata=metadata, + ) + with pytest.raises(_ClientPauseError) as exc_info: + for progress in session1.iter_upload( + stream, size=upload_size, content_type="video/mp4" + ): + if ( + progress.state == ProgressState.UPLOADING + and progress.bytes_uploaded >= chunk_size + ): + raise _ClientPauseError( + "User paused upload", + upload_url=progress.upload_url, + chunk_size=session1.chunk_size, + ) + + saved_url = exc_info.value.upload_url + saved_chunk_size = exc_info.value.chunk_size + assert saved_url + assert saved_chunk_size >= chunk_size + assert session1.bytes_uploaded >= chunk_size + + stream.seek(0) + session2 = client.create_you_tube_video_upload( + request=request_payload, + metadata=metadata, + ) + progress_records_2: List[UploadProgress] = [] + for progress in session2.iter_resume( + saved_url, + stream, + size=upload_size, + chunk_size=saved_chunk_size, + ): + progress_records_2.append(progress) + + response = session2.response + assert response is not None + assert re.match( + r"^customers/\d+/youTubeVideoUploads/\d+$", + response.resource_name, + ) + assert len(progress_records_2) >= 2 + assert progress_records_2[0].state == ProgressState.OFFSET_RECEIVED + assert progress_records_2[0].bytes_uploaded >= chunk_size + assert progress_records_2[-1].state == ProgressState.FINALIZED + assert progress_records_2[-1].bytes_uploaded == upload_size + finally: + stream.close() + if response is not None and response.resource_name: + _try_remove_upload( + client, customer_id, response.resource_name, metadata + ) + + if os.environ.get("GAPIC_PYTHON_ASYNC", "true") == "true": + + @pytest.mark.asyncio + @pytest.mark.parametrize("transport", ["grpc_asyncio", "rest_asyncio"]) + async def test_async_upload_with_impersonated_credentials( + self, transport: str + ): + """Verify complete asynchronous resumable video upload and resource cleanup. + + Args: + transport: Async client transport name (`"grpc_asyncio"` or `"rest_asyncio"`). + """ + if ( + transport == "rest_asyncio" + and "rest_asyncio" + not in YouTubeVideoUploadServiceClient._transport_registry + ): + pytest.fail( + "rest_asyncio transport is not registered when rest_async_io_enabled is False." + ) + client = YouTubeVideoUploadServiceAsyncClient( + credentials=_load_impersonated_credentials(), + transport=transport, + ) + customer_id = _google_ads_customer_id() + metadata = _google_ads_metadata() + stream, upload_size = _open_upload_file() + response = None + try: + upload_session = await client.create_you_tube_video_upload( + request={ + "customer_id": customer_id, + "you_tube_video_upload": { + "video_title": _generate_video_title(), + "video_description": "Testing defaults", + "video_privacy": "UNLISTED", + }, + }, + metadata=metadata, + ) + progress_records: List[UploadProgress] = [] + async for progress in upload_session.upload( + stream, size=upload_size, content_type="video/mp4" + ): + progress_records.append(progress) + + response = upload_session.response + assert response is not None + assert re.match( + r"^customers/\d+/youTubeVideoUploads/\d+$", + response.resource_name, + ) + assert len(progress_records) >= 2 + assert progress_records[0].state == ProgressState.STARTED + assert progress_records[-1].state == ProgressState.FINALIZED + assert progress_records[-1].bytes_uploaded == upload_size + finally: + stream.close() + if response is not None and response.resource_name: + await _async_try_remove_upload( + client, customer_id, response.resource_name, metadata + ) + + @pytest.mark.asyncio + @pytest.mark.parametrize("transport", ["grpc_asyncio", "rest_asyncio"]) + async def test_async_golden_user_style_resume_seekable(self, transport: str): + """Verify asynchronous mid-stream interruption, session resumption, and cleanup. + + Args: + transport: Async client transport name (`"grpc_asyncio"` or `"rest_asyncio"`). + """ + if ( + transport == "rest_asyncio" + and "rest_asyncio" + not in YouTubeVideoUploadServiceClient._transport_registry + ): + pytest.fail( + "rest_asyncio transport is not registered when rest_async_io_enabled is False." + ) + client = YouTubeVideoUploadServiceAsyncClient( + credentials=_load_impersonated_credentials(), + transport=transport, + ) + customer_id = _google_ads_customer_id() + metadata = _google_ads_metadata() + stream, upload_size = _open_upload_file() + chunk_size = 512 * 1024 # 512 KiB + request_payload = { + "customer_id": customer_id, + "you_tube_video_upload": { + "video_title": _generate_video_title(), + "video_description": "Testing defaults", + "video_privacy": "UNLISTED", + }, + } + response = None + try: + session1 = await client.create_you_tube_video_upload( + request=request_payload, + config=ResumableUploadConfig(chunk_size=chunk_size), + metadata=metadata, + ) + with pytest.raises(_ClientPauseError) as exc_info: + async for progress in session1.upload( + stream, size=upload_size, content_type="video/mp4" + ): + if ( + progress.state == ProgressState.UPLOADING + and progress.bytes_uploaded >= chunk_size + ): + raise _ClientPauseError( + "User paused upload", + upload_url=progress.upload_url, + chunk_size=session1.chunk_size, + ) + + saved_url = exc_info.value.upload_url + saved_chunk_size = exc_info.value.chunk_size + assert saved_url + assert saved_chunk_size >= chunk_size + assert session1.bytes_uploaded >= chunk_size + + stream.seek(0) + session2 = await client.create_you_tube_video_upload( + request=request_payload, + metadata=metadata, + ) + progress_records_2: List[UploadProgress] = [] + async for progress in session2.resume( + saved_url, + stream, + size=upload_size, + chunk_size=saved_chunk_size, + ): + progress_records_2.append(progress) + + response = session2.response + assert response is not None + assert re.match( + r"^customers/\d+/youTubeVideoUploads/\d+$", + response.resource_name, + ) + assert len(progress_records_2) >= 2 + assert progress_records_2[0].state == ProgressState.OFFSET_RECEIVED + assert progress_records_2[0].bytes_uploaded >= chunk_size + assert progress_records_2[-1].state == ProgressState.FINALIZED + assert progress_records_2[-1].bytes_uploaded == upload_size + finally: + stream.close() + if response is not None and response.resource_name: + await _async_try_remove_upload( + client, customer_id, response.resource_name, metadata + )