diff --git a/.codegen.json b/.codegen.json index c8bf4451..06ea9b6c 100644 --- a/.codegen.json +++ b/.codegen.json @@ -1 +1 @@ -{ "engineHash": "482939a", "specHash": "77eac4b", "version": "4.4.0" } +{ "engineHash": "bc04b80", "specHash": "77eac4b", "version": "4.4.0" } diff --git a/box_sdk_gen/client.py b/box_sdk_gen/client.py index 155301d2..f141a130 100644 --- a/box_sdk_gen/client.py +++ b/box_sdk_gen/client.py @@ -202,6 +202,8 @@ from box_sdk_gen.networking.base_urls import BaseUrls +from box_sdk_gen.networking.timeout_config import TimeoutConfig + from box_sdk_gen.networking.proxy_config import ProxyConfig @@ -545,3 +547,14 @@ def with_proxy(self, config: ProxyConfig) -> 'BoxClient': return BoxClient( auth=self.auth, network_session=self.network_session.with_proxy(config) ) + + def with_timeouts(self, config: TimeoutConfig) -> 'BoxClient': + """ + Create a new client with custom timeouts that will be used for every API call + :param config: Timeout configuration. + :type config: TimeoutConfig + """ + return BoxClient( + auth=self.auth, + network_session=self.network_session.with_timeout_config(config), + ) diff --git a/box_sdk_gen/networking/__init__.py b/box_sdk_gen/networking/__init__.py index c12260c1..84da5517 100644 --- a/box_sdk_gen/networking/__init__.py +++ b/box_sdk_gen/networking/__init__.py @@ -1,5 +1,7 @@ from box_sdk_gen.networking.box_network_client import * +from box_sdk_gen.networking.timeout_config import * + from box_sdk_gen.networking.proxy_config import * from box_sdk_gen.networking.network import * diff --git a/box_sdk_gen/networking/box_network_client.py b/box_sdk_gen/networking/box_network_client.py index 7a4f4464..3edeef99 100644 --- a/box_sdk_gen/networking/box_network_client.py +++ b/box_sdk_gen/networking/box_network_client.py @@ -3,7 +3,7 @@ import time from collections import OrderedDict from dataclasses import dataclass -from typing import Optional, Dict, Union +from typing import Optional, Dict, Union, Tuple from sys import version_info as py_version import requests @@ -17,6 +17,7 @@ from ..box.errors import BoxAPIError, BoxSDKError, RequestInfo, ResponseInfo from ..internal.utils import ByteStream, ResponseByteStream from ..networking.network_client import NetworkClient +from ..networking.timeout_config import TimeoutConfig from ..serialization.json import ( sd_to_json, sd_to_url_params, @@ -40,6 +41,7 @@ class APIRequest: params: Dict[str, str] data: Optional[Union[str, ByteStream, MultipartEncoder]] allow_redirects: bool = True + timeout: Optional[Tuple[Optional[float], Optional[float]]] = None @dataclass @@ -151,6 +153,7 @@ def _prepare_request( options.content_type, options.file_stream or options.data ) allow_redirects = options.follow_redirects + timeout = self._get_request_timeout(options) if options.content_type: if options.content_type == 'multipart/form-data': @@ -178,8 +181,43 @@ def _prepare_request( params=params, data=data, allow_redirects=allow_redirects, + timeout=timeout, ) + @staticmethod + def _get_request_timeout( + options: 'FetchOptions', + ) -> Optional[Tuple[Optional[float], Optional[float]]]: + """ + Derive requests timeout tuple (connect, read) in seconds. + + Uses `options.network_session.timeout_config` when present. + The timeout config values are expected to be in milliseconds. + """ + network_session = options.network_session + timeout_config = network_session.timeout_config if network_session else None + if timeout_config is None: + return None + + connection_timeout_ms, read_timeout_ms = ( + timeout_config.connection_timeout_ms, + timeout_config.read_timeout_ms, + ) + + if connection_timeout_ms is None and read_timeout_ms is None: + return None + + connection_timeout_sec = ( + connection_timeout_ms / 1000.0 + if connection_timeout_ms is not None + else None + ) + read_timeout_sec = ( + read_timeout_ms / 1000.0 if read_timeout_ms is not None else None + ) + + return (connection_timeout_sec, read_timeout_sec) + @staticmethod def _prepare_headers( options: 'FetchOptions', reauthenticate: bool = False @@ -216,12 +254,12 @@ def _prepare_body( or content_type == 'application/octet-stream' ): return data - raise + raise ValueError(f'Unsupported content type: {content_type}') def _make_request(self, request: APIRequest) -> APIResponse: raised_exception = None reauthentication_needed = False - default_timeout = (5, 60) # connect, read timeout + timeout = request.timeout try: network_response = self.requests_session.request( method=request.method, @@ -231,7 +269,7 @@ def _make_request(self, request: APIRequest) -> APIResponse: params=request.params, allow_redirects=request.allow_redirects, stream=True, - timeout=default_timeout, + timeout=timeout, ) except RequestException as request_exc: raised_exception = request_exc diff --git a/box_sdk_gen/networking/network.py b/box_sdk_gen/networking/network.py index c51f8722..a3ed2fb2 100644 --- a/box_sdk_gen/networking/network.py +++ b/box_sdk_gen/networking/network.py @@ -6,6 +6,7 @@ from .proxy_config import ProxyConfig from .base_urls import BaseUrls from .retries import RetryStrategy, BoxRetryStrategy +from .timeout_config import TimeoutConfig class NetworkSession: @@ -18,6 +19,7 @@ def __init__( base_urls: BaseUrls = None, proxy_url: str = None, data_sanitizer: DataSanitizer = None, + timeout_config: TimeoutConfig = None, ): if additional_headers is None: additional_headers = {} @@ -38,12 +40,18 @@ def __init__( } if data_sanitizer is None: data_sanitizer = DataSanitizer() + if timeout_config is None: + timeout_config = TimeoutConfig( + connection_timeout_ms=5000, + read_timeout_ms=60000, + ) self.additional_headers = additional_headers self.base_urls = base_urls self.proxy_url = proxy_url self.network_client = network_client self.retry_strategy = retry_strategy self.data_sanitizer = data_sanitizer + self.timeout_config = timeout_config def with_additional_headers( self, additional_headers: Dict[str, str] = None @@ -61,6 +69,7 @@ def with_additional_headers( proxy_url=self.proxy_url, retry_strategy=self.retry_strategy, data_sanitizer=self.data_sanitizer, + timeout_config=self.timeout_config, ) def with_custom_base_urls(self, base_urls: BaseUrls) -> 'NetworkSession': @@ -77,6 +86,7 @@ def with_custom_base_urls(self, base_urls: BaseUrls) -> 'NetworkSession': proxy_url=self.proxy_url, retry_strategy=self.retry_strategy, data_sanitizer=self.data_sanitizer, + timeout_config=self.timeout_config, ) def with_proxy(self, config: ProxyConfig) -> 'NetworkSession': @@ -103,6 +113,7 @@ def with_proxy(self, config: ProxyConfig) -> 'NetworkSession': proxy_url=proxy_url, retry_strategy=self.retry_strategy, data_sanitizer=self.data_sanitizer, + timeout_config=self.timeout_config, ) def with_network_client(self, network_client: NetworkClient) -> 'NetworkSession': @@ -119,6 +130,7 @@ def with_network_client(self, network_client: NetworkClient) -> 'NetworkSession' proxy_url=self.proxy_url, retry_strategy=self.retry_strategy, data_sanitizer=self.data_sanitizer, + timeout_config=self.timeout_config, ) def with_retry_strategy(self, retry_strategy: RetryStrategy) -> 'NetworkSession': @@ -135,6 +147,7 @@ def with_retry_strategy(self, retry_strategy: RetryStrategy) -> 'NetworkSession' proxy_url=self.proxy_url, retry_strategy=retry_strategy, data_sanitizer=self.data_sanitizer, + timeout_config=self.timeout_config, ) def with_data_sanitizer(self, data_sanitizer: DataSanitizer) -> 'NetworkSession': @@ -151,4 +164,22 @@ def with_data_sanitizer(self, data_sanitizer: DataSanitizer) -> 'NetworkSession' proxy_url=self.proxy_url, retry_strategy=self.retry_strategy, data_sanitizer=data_sanitizer, + timeout_config=self.timeout_config, + ) + + def with_timeout_config(self, timeout_config: TimeoutConfig) -> 'NetworkSession': + """ + Generate a fresh network session by duplicating the existing configuration and network parameters, + while also including timeout config to be used for every API call. + :param timeout_config: TimeoutConfig object, which contains the timeout config + :return: a new instance of NetworkSession + """ + return NetworkSession( + network_client=self.network_client, + additional_headers=self.additional_headers, + base_urls=self.base_urls, + proxy_url=self.proxy_url, + retry_strategy=self.retry_strategy, + data_sanitizer=self.data_sanitizer, + timeout_config=timeout_config, ) diff --git a/box_sdk_gen/networking/timeout_config.py b/box_sdk_gen/networking/timeout_config.py new file mode 100644 index 00000000..79772f7b --- /dev/null +++ b/box_sdk_gen/networking/timeout_config.py @@ -0,0 +1,12 @@ +from typing import Optional + + +class TimeoutConfig: + def __init__( + self, + *, + connection_timeout_ms: Optional[int] = None, + read_timeout_ms: Optional[int] = None + ): + self.connection_timeout_ms = connection_timeout_ms + self.read_timeout_ms = read_timeout_ms diff --git a/docs/box_sdk_gen/client.md b/docs/box_sdk_gen/client.md index d5e5c437..4a38797e 100644 --- a/docs/box_sdk_gen/client.md +++ b/docs/box_sdk_gen/client.md @@ -15,6 +15,7 @@ divided across resource managers. - [Suppress notifications](#suppress-notifications) - [Custom headers](#custom-headers) - [Custom Base URLs](#custom-base-urls) +- [Use Timeouts for API calls](#use-timeouts-for-api-calls) - [Use Proxy for API calls](#use-proxy-for-api-calls) @@ -151,6 +152,18 @@ new_client = client.with_custom_base_urls(base_urls=BaseUrls( )) ``` +# Use Timeouts for API calls + +In order to configure timeout for API calls, calling the `client.with_timeouts(config)` method creates a new client with timeout settings, leaving the original client unmodified. + +```python +timeout_config = TimeoutConfig( + connection_timeout_ms=10000, + read_timeout_ms=30000 +) +new_client = client.with_timeouts(timeout_config) +``` + # Use Proxy for API calls In order to use a proxy for API calls, calling the `client.with_proxy(proxyConfig)` method creates a new client, leaving the original client unmodified, with the username and password being optional. diff --git a/docs/box_sdk_gen/configuration.md b/docs/box_sdk_gen/configuration.md index 9bb75b16..4e02e8ce 100644 --- a/docs/box_sdk_gen/configuration.md +++ b/docs/box_sdk_gen/configuration.md @@ -13,6 +13,7 @@ - [Network Exception Handling](#network-exception-handling) - [Customizing Retry Parameters](#customizing-retry-parameters) - [Custom Retry Strategy](#custom-retry-strategy) +- [Timeouts](#timeouts) @@ -178,3 +179,30 @@ auth = BoxDeveloperTokenAuth(token='DEVELOPER_TOKEN_GOES_HERE') network_session = NetworkSession(retry_strategy=CustomRetryStrategy()) client = BoxClient(auth=auth, network_session=network_session) ``` + +## Timeouts + +You can configure network timeouts with `TimeoutConfig` on `NetworkSession`. +Python SDK supports separate connection and read timeout values in milliseconds. + +```python +from box_sdk_gen import BoxClient, BoxDeveloperTokenAuth, NetworkSession, TimeoutConfig + +auth = BoxDeveloperTokenAuth(token='DEVELOPER_TOKEN_GOES_HERE') +timeout_config = TimeoutConfig( + connection_timeout_ms=10000, + read_timeout_ms=30000, +) +network_session = NetworkSession(timeout_config=timeout_config) +client = BoxClient(auth=auth, network_session=network_session) +``` + +How timeout handling works: + +- Timeout values are configured in milliseconds and converted to seconds internally for HTTP requests. +- The SDK uses default timeouts when timeout config is not provided: `connection_timeout_ms=5000` and `read_timeout_ms=60000`. +- To disable all SDK timeouts, pass `TimeoutConfig(connection_timeout_ms=None, read_timeout_ms=None)` explicitly to `NetworkSession`. +- You can also disable only one timeout by setting one value to `None` (for example, `connection_timeout_ms=None` or `read_timeout_ms=None`). If you provide only the other value (for example, `read_timeout_ms=30000`) and leave one unspecified, the unspecified field remains `None` and that timeout stays disabled. +- Timeout failures are treated as network exceptions, and retry behavior is controlled by the configured retry strategy. +- Timeout applies to a single HTTP request attempt to the Box API (not the total time across all retries). +- If retries are exhausted, the SDK raises `BoxSDKError` with the underlying request exception. diff --git a/test/box_sdk_gen/test/box_network_client.py b/test/box_sdk_gen/test/box_network_client.py index ab95206e..bfd00384 100644 --- a/test/box_sdk_gen/test/box_network_client.py +++ b/test/box_sdk_gen/test/box_network_client.py @@ -175,6 +175,25 @@ def network_session_mock(): return NetworkSession() +def test_network_session_uses_default_timeout_config_values(): + network_session = NetworkSession() + + assert network_session.timeout_config.connection_timeout_ms == 5000 + assert network_session.timeout_config.read_timeout_ms == 60000 + + +def test_prepare_request_uses_default_network_session_timeouts(network_client): + options = FetchOptions( + url="https://example.com", + method="GET", + network_session=NetworkSession(), + ) + + api_request = network_client._prepare_request(options=options) + + assert api_request.timeout == (5, 60) + + @pytest.fixture def network_client(mock_requests_session): return BoxNetworkClient(mock_requests_session) @@ -295,7 +314,7 @@ def test_prepare_body_invalid_content_type(network_client): network_client._prepare_body("invalid_content_type", {}) -def test_prepare_json_request(network_client): +def test_prepare_json_request(network_client, network_session_mock): options = FetchOptions( url="https://example.com", method="POST", @@ -303,6 +322,7 @@ def test_prepare_json_request(network_client): headers={"header": "test"}, params={"param": "value"}, content_type="application/json", + network_session=network_session_mock, ) api_request = network_client._prepare_request(options=options) @@ -318,6 +338,7 @@ def test_prepare_json_request(network_client): }, params={"param": "value"}, data='{"key": "value"}', + timeout=(5, 60), ) @@ -379,7 +400,7 @@ def test_make_request(network_client, mock_requests_session, response_200): ) assert mock_requests_session.request.call_count == 1 mock_requests_session.request.assert_called_once_with( - **request_params, stream=True, timeout=(5, 60) + **request_params, stream=True, timeout=None ) diff --git a/test/box_sdk_gen/test/client.py b/test/box_sdk_gen/test/client.py index 3924ad9b..207b80a4 100644 --- a/test/box_sdk_gen/test/client.py +++ b/test/box_sdk_gen/test/client.py @@ -26,6 +26,8 @@ from box_sdk_gen.schemas.user_full import UserFull +from box_sdk_gen.networking.timeout_config import TimeoutConfig + from box_sdk_gen.internal.utils import get_uuid from box_sdk_gen.internal.utils import generate_byte_stream @@ -218,3 +220,20 @@ def testWithCustomBaseUrls(): custom_base_client: BoxClient = client.with_custom_base_urls(new_base_urls) with pytest.raises(Exception): custom_base_client.users.get_user_me() + + +def testWithTimeoutWhenTimeoutOccurs(): + read_timeout_ms: int = 1 + client_with_timeout: BoxClient = client.with_timeouts( + TimeoutConfig(read_timeout_ms=read_timeout_ms) + ) + with pytest.raises(Exception): + client_with_timeout.users.get_user_me() + + +def testWithTimeoutWhenTimeoutDoesNotOccur(): + read_timeout_ms: int = 10000 + client_with_timeout: BoxClient = client.with_timeouts( + TimeoutConfig(read_timeout_ms=read_timeout_ms) + ) + client_with_timeout.users.get_user_me()