diff --git a/google/genai/client.py b/google/genai/client.py index 55663b888..942391353 100644 --- a/google/genai/client.py +++ b/google/genai/client.py @@ -43,6 +43,7 @@ from . import _common from ._interactions import AsyncGeminiNextGenAPIClient, DEFAULT_MAX_RETRIES, GeminiNextGenAPIClient +from ._interactions._types import NOT_GIVEN from . import _interactions from ._interactions.resources import AsyncInteractionsResource as AsyncNextGenInteractionsResource, InteractionsResource as NextGenInteractionsResource @@ -175,7 +176,9 @@ def _nextgen_client(self) -> AsyncGeminiNextGenAPIClient: default_headers=http_opts.headers, http_client=http_client, # uSDk expects ms, nextgen uses a httpx Timeout -> expects seconds. - timeout=http_opts.timeout / 1000 if http_opts.timeout else None, + # Pass NOT_GIVEN (not None) when unset so the Stainless client uses + # its DEFAULT_TIMEOUT. httpx treats None as "no timeout". + timeout=http_opts.timeout / 1000 if http_opts.timeout else NOT_GIVEN, max_retries=max_retries, client_adapter=AsyncGeminiNextGenAPIClientAdapter(self._api_client) ) @@ -552,7 +555,9 @@ def _nextgen_client(self) -> GeminiNextGenAPIClient: default_headers=http_opts.headers, http_client=self._api_client._httpx_client, # uSDk expects ms, nextgen uses a httpx Timeout -> expects seconds. - timeout=http_opts.timeout / 1000 if http_opts.timeout else None, + # Pass NOT_GIVEN (not None) when unset so the Stainless client uses + # its DEFAULT_TIMEOUT. httpx treats None as "no timeout". + timeout=http_opts.timeout / 1000 if http_opts.timeout else NOT_GIVEN, max_retries=max_retries, client_adapter=GeminiNextGenAPIClientAdapter(self._api_client), ) diff --git a/google/genai/tests/interactions/test_default_timeout.py b/google/genai/tests/interactions/test_default_timeout.py new file mode 100644 index 000000000..125b5c38e --- /dev/null +++ b/google/genai/tests/interactions/test_default_timeout.py @@ -0,0 +1,131 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for default timeout behavior of the interactions client. + +When no timeout is configured in http_options, the wrapper must pass +NOT_GIVEN to the Stainless client so it falls back to DEFAULT_TIMEOUT +(60s). Previously it passed None, which httpx interprets as "no timeout" +causing requests to hang indefinitely. +""" + +from unittest import mock + +import pytest + +from ... import client as client_lib +from ..._interactions._types import NotGiven + +pytest_plugins = ("pytest_asyncio",) + + +class TestSyncDefaultTimeout: + """Sync client default timeout tests.""" + + def test_default_timeout_is_not_given(self): + """When no timeout is set, NOT_GIVEN (not None) should be passed.""" + with mock.patch.object( + client_lib, "GeminiNextGenAPIClient", spec_set=True + ) as mock_nextgen_client: + client = client_lib.Client( + api_key="placeholder", + http_options={"api_version": "v1alpha"}, + ) + _ = client.interactions + + timeout_arg = mock_nextgen_client.call_args.kwargs["timeout"] + assert isinstance(timeout_arg, NotGiven), ( + f"Expected timeout to be NOT_GIVEN but got {timeout_arg!r}. " + f"None would disable httpx timeouts entirely." + ) + + def test_explicit_timeout_passes_through(self): + """When timeout is set, it should pass through as seconds (ms / 1000).""" + with mock.patch.object( + client_lib, "GeminiNextGenAPIClient", spec_set=True + ) as mock_nextgen_client: + client = client_lib.Client( + api_key="placeholder", + http_options={"api_version": "v1alpha", "timeout": 30000}, + ) + _ = client.interactions + + mock_nextgen_client.assert_called_once_with( + base_url=mock.ANY, + api_key="placeholder", + api_version="v1alpha", + default_headers=mock.ANY, + http_client=mock.ANY, + timeout=30.0, + max_retries=mock.ANY, + client_adapter=mock.ANY, + ) + + def test_no_http_options_uses_not_given(self): + """When no http_options at all, timeout should still be NOT_GIVEN.""" + with mock.patch.object( + client_lib, "GeminiNextGenAPIClient", spec_set=True + ) as mock_nextgen_client: + client = client_lib.Client(api_key="placeholder") + _ = client.interactions + + timeout_arg = mock_nextgen_client.call_args.kwargs["timeout"] + assert isinstance(timeout_arg, NotGiven), ( + f"Expected timeout to be NOT_GIVEN but got {timeout_arg!r}." + ) + + +class TestAsyncDefaultTimeout: + """Async client default timeout tests.""" + + @pytest.mark.asyncio + async def test_default_timeout_is_not_given(self): + """When no timeout is set, NOT_GIVEN (not None) should be passed.""" + with mock.patch.object( + client_lib, "AsyncGeminiNextGenAPIClient", spec_set=True + ) as mock_nextgen_client: + client = client_lib.Client( + api_key="placeholder", + http_options={"api_version": "v1alpha"}, + ) + _ = client.aio.interactions + + timeout_arg = mock_nextgen_client.call_args.kwargs["timeout"] + assert isinstance(timeout_arg, NotGiven), ( + f"Expected timeout to be NOT_GIVEN but got {timeout_arg!r}. " + f"None would disable httpx timeouts entirely." + ) + + @pytest.mark.asyncio + async def test_explicit_timeout_passes_through(self): + """When timeout is set, it should pass through as seconds (ms / 1000).""" + with mock.patch.object( + client_lib, "AsyncGeminiNextGenAPIClient", spec_set=True + ) as mock_nextgen_client: + client = client_lib.Client( + api_key="placeholder", + http_options={"api_version": "v1alpha", "timeout": 30000}, + ) + _ = client.aio.interactions + + mock_nextgen_client.assert_called_once_with( + base_url=mock.ANY, + api_key="placeholder", + api_version="v1alpha", + default_headers=mock.ANY, + http_client=mock.ANY, + timeout=30.0, + max_retries=mock.ANY, + client_adapter=mock.ANY, + )