diff --git a/.codegen.json b/.codegen.json index cf002cd6..6c17bf8d 100644 --- a/.codegen.json +++ b/.codegen.json @@ -1 +1 @@ -{ "engineHash": "bfb97cc", "specHash": "77eac4b", "version": "10.4.0" } +{ "engineHash": "bc04b80", "specHash": "f2523d5", "version": "10.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/managers/archives.py b/box_sdk_gen/managers/archives.py index 146b58f8..24c64bd2 100644 --- a/box_sdk_gen/managers/archives.py +++ b/box_sdk_gen/managers/archives.py @@ -158,6 +158,15 @@ def delete_archive_by_id_v2025_r0( To learn more about the archive APIs, see the [Archive API Guide](https://developer.box.com/guides/archives). + + + + + This endpoint is currently unavailable. Please contact support for assistance. + + + + :param archive_id: The ID of the archive. Example: "982312" :type archive_id: str 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/box_sdk_gen/schemas/__init__.py b/box_sdk_gen/schemas/__init__.py index a4132c26..5ed9f1f5 100644 --- a/box_sdk_gen/schemas/__init__.py +++ b/box_sdk_gen/schemas/__init__.py @@ -358,10 +358,10 @@ from box_sdk_gen.schemas.upload_part import * -from box_sdk_gen.schemas.uploaded_part import * - from box_sdk_gen.schemas.upload_parts import * +from box_sdk_gen.schemas.uploaded_part import * + from box_sdk_gen.schemas.upload_session import * from box_sdk_gen.schemas.upload_url import * @@ -500,6 +500,12 @@ from box_sdk_gen.schemas.watermark import * +from box_sdk_gen.schemas.webhook_mini import * + +from box_sdk_gen.schemas.webhooks import * + +from box_sdk_gen.schemas.webhook import * + from box_sdk_gen.schemas.web_link_base import * from box_sdk_gen.schemas.web_link_mini import * @@ -568,12 +574,6 @@ from box_sdk_gen.schemas.app_item_associations import * -from box_sdk_gen.schemas.webhook_mini import * - -from box_sdk_gen.schemas.webhooks import * - -from box_sdk_gen.schemas.webhook import * - from box_sdk_gen.schemas.workflow_mini import * from box_sdk_gen.schemas.workflow import * diff --git a/docs/archives.md b/docs/archives.md index 33a2dd12..da8f30ba 100644 --- a/docs/archives.md +++ b/docs/archives.md @@ -81,6 +81,10 @@ Permanently deletes an archive. To learn more about the archive APIs, see the [Archive API Guide](https://developer.box.com/guides/archives). + +This endpoint is currently unavailable. Please contact support for assistance. + + This operation is performed by calling function `delete_archive_by_id_v2025_r0`. See the endpoint docs at diff --git a/docs/client.md b/docs/client.md index 48b11a45..7fa552e2 100644 --- a/docs/client.md +++ b/docs/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) @@ -153,6 +154,15 @@ new_client = client.with_custom_base_urls( ) ``` +# 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/configuration.md b/docs/configuration.md index 4c10d7f8..6e950087 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -3,15 +3,135 @@ -- [Max retry attempts](#max-retry-attempts) -- [Custom retry strategy](#custom-retry-strategy) +- [Retry Strategy](#retry-strategy) + - [Overview](#overview) + - [Default Configuration](#default-configuration) + - [Retry Decision Flow](#retry-decision-flow) + - [Exponential Backoff Algorithm](#exponential-backoff-algorithm) + - [Example Delays (with default settings)](#example-delays-with-default-settings) + - [Retry-After Header](#retry-after-header) + - [Network Exception Handling](#network-exception-handling) + - [Customizing Retry Parameters](#customizing-retry-parameters) + - [Custom Retry Strategy](#custom-retry-strategy) +- [Timeouts](#timeouts) -## Max retry attempts +## Retry Strategy -The default maximum number of retries in case of failed API call is 5. -To change this number you should initialize `BoxRetryStrategy` with the new value and pass it to `NetworkSession`. +### Overview + +The SDK ships with a built-in retry strategy (`BoxRetryStrategy`) that implements the `RetryStrategy` interface. The `BoxNetworkClient`, which serves as the default network client, uses this strategy to automatically retry failed API requests with exponential backoff. + +The retry strategy exposes two methods: + +- **`should_retry`** — Determines whether a failed request should be retried based on the HTTP status code, response headers, attempt count, and authentication state. +- **`retry_after`** — Computes the delay (in seconds) before the next retry attempt, using either the server-provided `Retry-After` header or an exponential backoff formula. + +### Default Configuration + +| Parameter | Default | Description | +| ---------------------------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `max_attempts` | `5` | Maximum number of retry attempts for HTTP error responses (status 4xx/5xx). | +| `retry_base_interval` | `1` (second) | Base interval used in the exponential backoff calculation. | +| `retry_randomization_factor` | `0.5` | Jitter factor applied to the backoff delay. The actual delay is multiplied by a random value between `1 - factor` and `1 + factor`. | +| `max_retries_on_exception` | `2` | Maximum number of retries for network-level exceptions (connection failures, timeouts). These are tracked by a separate counter from HTTP error retries. | + +### Retry Decision Flow + +The following diagram shows how `BoxRetryStrategy.should_retry` decides whether to retry a request: + +``` + should_retry(fetch_options, fetch_response, attempt_number) + | + v + +-----------------------+ + | status == 0 | Yes + | (network exception)? |----------> attempt_number <= max_retries_on_exception? + +-----------------------+ | | + | No Yes No + v | | + +-----------------------+ [RETRY] [NO RETRY] + | attempt_number >= | + | max_attempts? | + +-----------------------+ + | | + Yes No + | | + [NO RETRY] v + +-----------------------+ + | status == 202 AND | Yes + | Retry-After header? |----------> [RETRY] + +-----------------------+ + | No + v + +-----------------------+ + | status >= 500 | Yes + | (server error)? |----------> [RETRY] + +-----------------------+ + | No + v + +-----------------------+ + | status == 429 | Yes + | (rate limited)? |----------> [RETRY] + +-----------------------+ + | No + v + +-----------------------+ + | status == 401 AND | Yes + | auth available? |----------> Refresh token, then [RETRY] + +-----------------------+ + | No + v + [NO RETRY] +``` + +### Exponential Backoff Algorithm + +When the response does not include a `Retry-After` header, the retry delay is computed using exponential backoff with randomized jitter: + +``` +delay = 2^attempt_number * retry_base_interval * random(1 - factor, 1 + factor) +``` + +Where: + +- `attempt_number` is the current attempt (1-based) +- `retry_base_interval` defaults to `1` second +- `factor` is `retry_randomization_factor` (default `0.5`) +- `random(min, max)` returns a uniformly distributed value in `[min, max]` + +#### Example Delays (with default settings) + +| Attempt | Base Delay | Min Delay (factor=0.5) | Max Delay (factor=0.5) | +| ------- | ---------- | ---------------------- | ---------------------- | +| 1 | 2s | 1.0s | 3.0s | +| 2 | 4s | 2.0s | 6.0s | +| 3 | 8s | 4.0s | 12.0s | +| 4 | 16s | 8.0s | 24.0s | + +### Retry-After Header + +When the server includes a `Retry-After` header in the response, the SDK uses the header value directly as the delay in seconds instead of computing an exponential backoff delay. This applies to any retryable response that includes the header, including: + +- `202 Accepted` with `Retry-After` (long-running operations) +- `429 Too Many Requests` with `Retry-After` +- `5xx` server errors with `Retry-After` + +The header value is parsed as a floating-point number representing seconds. + +### Network Exception Handling + +Network-level failures (connection refused, DNS resolution errors, timeouts, TLS errors) are represented internally as responses with status `0`. These exceptions are tracked by a **separate counter** (`max_retries_on_exception`, default `2`) from the regular HTTP error retry counter (`max_attempts`). + +This means: + +- Network exception retries are tracked independently from HTTP error retries, each with their own counter and backoff progression. +- A request can fail up to `max_retries_on_exception` times due to network exceptions, but each exception retry also increments the overall attempt counter, so the total number of retries across both exception and HTTP error types is bounded by `max_attempts`. + +### Customizing Retry Parameters + +You can customize all retry parameters by initializing `BoxRetryStrategy` with the desired values and passing it to `NetworkSession`: ```python from box_sdk_gen import ( @@ -22,14 +142,20 @@ from box_sdk_gen import ( ) auth = BoxDeveloperTokenAuth(token="DEVELOPER_TOKEN_GOES_HERE") -network_session = NetworkSession(retry_strategy=BoxRetryStrategy(max_attempts=6)) +network_session = NetworkSession( + retry_strategy=BoxRetryStrategy( + max_attempts=3, + retry_base_interval=2, + retry_randomization_factor=0.3, + max_retries_on_exception=1, + ) +) client = BoxClient(auth=auth, network_session=network_session) ``` -## Custom retry strategy +### Custom Retry Strategy -You can also implement your own retry strategy by subclassing `RetryStrategy` and overriding `should_retry` and `retry_after` methods. -This example shows how to set custom strategy that retries on 5xx status codes and waits 1 second between retries. +You can implement your own retry strategy by subclassing `RetryStrategy` and overriding the `should_retry` and `retry_after` methods: ```python from box_sdk_gen import ( @@ -49,7 +175,7 @@ class CustomRetryStrategy(RetryStrategy): fetch_response: FetchResponse, attempt_number: int, ) -> bool: - return fetch_response.status_code >= 500 + return fetch_response.status >= 500 and attempt_number < 3 def retry_after( self, @@ -64,3 +190,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/archives.py b/test/archives.py index 65c2e6ed..83d3ef39 100644 --- a/test/archives.py +++ b/test/archives.py @@ -37,6 +37,5 @@ def testArchivesCreateListDelete(): assert updated_archive.description == new_archive_description archives: ArchivesV2025R0 = client.archives.get_archives_v2025_r0(limit=100) assert len(archives.entries) > 0 - client.archives.delete_archive_by_id_v2025_r0(archive.id) with pytest.raises(Exception): client.archives.delete_archive_by_id_v2025_r0(archive.id) diff --git a/test/box_network_client.py b/test/box_network_client.py index ab95206e..bfd00384 100644 --- a/test/box_network_client.py +++ b/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/client.py b/test/client.py index 91eeb8ef..53853faf 100644 --- a/test/client.py +++ b/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()