From e93828b9ffff96efbd6b899ca59762235a922794 Mon Sep 17 00:00:00 2001 From: Mark Daoust Date: Sun, 17 May 2026 16:08:49 -0700 Subject: [PATCH] fix(interactions): use NOT_GIVEN instead of None for default timeout When http_opts.timeout is not set by the user, the google.genai wrapper was passing timeout=None to the Stainless client. In httpx, None means 'no timeout' (wait forever). The Stainless SyncAPIClient.__init__ only falls back to DEFAULT_TIMEOUT (60s) when the value is NotGiven, not None. This caused all interactions requests to have no timeout at all, hanging indefinitely on unresponsive servers instead of raising APITimeoutError after 60 seconds. PiperOrigin-RevId: 916923417 --- google/genai/client.py | 9 +- .../interactions/test_default_timeout.py | 131 ++++++++++++++++++ 2 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 google/genai/tests/interactions/test_default_timeout.py 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, + )