From f1b50bf493bce639a6429bf5231adbbb8b46cc80 Mon Sep 17 00:00:00 2001 From: codechrl Date: Mon, 7 Sep 2026 12:02:35 +0700 Subject: [PATCH 1/3] fix: apply a default timeout to every HTTP request Every session call in InfisicalRequests ran without a timeout argument. The requests default of timeout=None leaves the socket with no deadline. A server that accepts a connection and never replies blocks the caller indefinitely. NETWORK_ERRORS already lists ReadTimeout and ConnectTimeout, so those retry branches were unreachable. Add a timeout parameter to InfisicalRequests and InfisicalSDKClient. It defaults to 30 seconds and is passed to every request. Passing None restores the previous behavior. --- infisical_sdk/client.py | 5 +++-- infisical_sdk/infisical_requests.py | 11 +++++----- tests/__init__.py | 0 tests/test_infisical_requests.py | 31 +++++++++++++++++++++++++++++ 4 files changed, 40 insertions(+), 7 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/test_infisical_requests.py diff --git a/infisical_sdk/client.py b/infisical_sdk/client.py index ce21277..83c45ac 100644 --- a/infisical_sdk/client.py +++ b/infisical_sdk/client.py @@ -9,19 +9,20 @@ from infisical_sdk.util import SecretsCache class InfisicalSDKClient: - def __init__(self, host: str, token: str = None, cache_ttl: int = 60): + def __init__(self, host: str, token: str = None, cache_ttl: int = 60, timeout: float = 30): """ Initialize the Infisical SDK client. :param str host: The host URL for your Infisical instance. Will default to `https://app.infisical.com` if not specified. :param str token: The authentication token for the client. If not specified, you can use the `auth` methods to authenticate. :param int cache_ttl: The time to live for the secrets cache. This is the number of seconds that secrets fetched from the API will be cached for. Set to `None` to disable caching. Defaults to `60` seconds. + :param float timeout: The number of seconds to wait for each HTTP request. Set to `None` to disable the timeout. Defaults to `30` seconds. """ self.host = host self.access_token = token - self.api = InfisicalRequests(host=host, token=token) + self.api = InfisicalRequests(host=host, token=token, timeout=timeout) self.cache = SecretsCache(cache_ttl) self.auth = Auth(self.api, self.set_token) self.secrets = V3RawSecrets(self.api, self.cache) diff --git a/infisical_sdk/infisical_requests.py b/infisical_sdk/infisical_requests.py index 0b91516..45662df 100644 --- a/infisical_sdk/infisical_requests.py +++ b/infisical_sdk/infisical_requests.py @@ -105,9 +105,10 @@ def wrapper(*args, **kwargs): class InfisicalRequests: - def __init__(self, host: str, token: Optional[str] = None): + def __init__(self, host: str, token: Optional[str] = None, timeout: Optional[float] = 30): self.host = host.rstrip("/") self.session = requests.Session() + self.timeout = timeout # Set common headers self.session.headers.update({ @@ -163,7 +164,7 @@ def get( model: model class to parse response into params: Optional query parameters """ - response = self.session.get(self._build_url(path), params=params) + response = self.session.get(self._build_url(path), params=params, timeout=self.timeout) data = self._handle_response(response) parsed_data = model.from_dict(data) if hasattr(model, 'from_dict') else data @@ -188,7 +189,7 @@ def post( # Filter out None values json = {k: v for k, v in json.items() if v is not None} - response = self.session.post(self._build_url(path), json=json) + response = self.session.post(self._build_url(path), json=json, timeout=self.timeout) data = self._handle_response(response) parsed_data = model.from_dict(data) if hasattr(model, 'from_dict') else data @@ -213,7 +214,7 @@ def patch( # Filter out None values json = {k: v for k, v in json.items() if v is not None} - response = self.session.patch(self._build_url(path), json=json) + response = self.session.patch(self._build_url(path), json=json, timeout=self.timeout) data = self._handle_response(response) parsed_data = model.from_dict(data) if hasattr(model, 'from_dict') else data @@ -238,7 +239,7 @@ def delete( # Filter out None values json = {k: v for k, v in json.items() if v is not None} - response = self.session.delete(self._build_url(path), json=json) + response = self.session.delete(self._build_url(path), json=json, timeout=self.timeout) data = self._handle_response(response) parsed_data = model.from_dict(data) if hasattr(model, 'from_dict') else data diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_infisical_requests.py b/tests/test_infisical_requests.py new file mode 100644 index 0000000..0c63322 --- /dev/null +++ b/tests/test_infisical_requests.py @@ -0,0 +1,31 @@ +from unittest import mock + +import pytest + +from infisical_sdk import InfisicalSDKClient +from infisical_sdk.infisical_requests import InfisicalRequests + + +def request_kwargs(api, method): + with mock.patch.object(api.session, method) as send: + send.return_value.headers = {} + getattr(api, method)("/path", dict) + return send.call_args.kwargs + + +@pytest.mark.parametrize("method", ["get", "post", "patch", "delete"]) +def test_request_carries_default_timeout(method): + api = InfisicalRequests(host="https://example.com") + + assert request_kwargs(api, method)["timeout"] == 30 + + +def test_request_carries_configured_timeout(): + api = InfisicalRequests(host="https://example.com", timeout=5) + + assert request_kwargs(api, "get")["timeout"] == 5 + + +def test_client_timeout_reaches_the_request(): + with InfisicalSDKClient(host="https://example.com", timeout=5) as client: + assert request_kwargs(client.api, "get")["timeout"] == 5 From c504d79bd17d1a2b278c409696faf9a7e8da7bd9 Mon Sep 17 00:00:00 2001 From: codechrl Date: Mon, 7 Sep 2026 13:28:38 +0700 Subject: [PATCH 2/3] fix: do not replay writes after a read timeout The new default made ReadTimeout reachable for the first time. It is in NETWORK_ERRORS, and post, patch and delete carry the same retry decorator as get, so a write whose response was slow would be resent up to four times after the server may already have applied it. Writes now retry on a set that excludes ReadTimeout. A connect timeout still retries on every verb, because the request never reached the server. A read timeout on a write reaches the caller. Also annotate the client timeout as Optional[float], since None is a documented value, and say in the docstring that the value applies to the connection and to the response on each attempt. --- infisical_sdk/client.py | 6 ++++-- infisical_sdk/infisical_requests.py | 19 ++++++++++++++++--- tests/test_infisical_requests.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/infisical_sdk/client.py b/infisical_sdk/client.py index 83c45ac..a3ba53c 100644 --- a/infisical_sdk/client.py +++ b/infisical_sdk/client.py @@ -1,3 +1,5 @@ +from typing import Optional + from .infisical_requests import InfisicalRequests from infisical_sdk.resources import Auth @@ -9,14 +11,14 @@ from infisical_sdk.util import SecretsCache class InfisicalSDKClient: - def __init__(self, host: str, token: str = None, cache_ttl: int = 60, timeout: float = 30): + def __init__(self, host: str, token: str = None, cache_ttl: int = 60, timeout: Optional[float] = 30): """ Initialize the Infisical SDK client. :param str host: The host URL for your Infisical instance. Will default to `https://app.infisical.com` if not specified. :param str token: The authentication token for the client. If not specified, you can use the `auth` methods to authenticate. :param int cache_ttl: The time to live for the secrets cache. This is the number of seconds that secrets fetched from the API will be cached for. Set to `None` to disable caching. Defaults to `60` seconds. - :param float timeout: The number of seconds to wait for each HTTP request. Set to `None` to disable the timeout. Defaults to `30` seconds. + :param float timeout: The number of seconds to wait for the connection and for the response, applied to each attempt. Set to `None` to disable the timeout. Defaults to `30` seconds. """ self.host = host diff --git a/infisical_sdk/infisical_requests.py b/infisical_sdk/infisical_requests.py index 45662df..9b3fc8f 100644 --- a/infisical_sdk/infisical_requests.py +++ b/infisical_sdk/infisical_requests.py @@ -22,6 +22,13 @@ ConnectionAbortedError, ] +# A read timeout means the request reached the server and the response did not +# arrive in time, so the server may already have applied it. Replaying that is +# only safe for reads. +IDEMPOTENT_NETWORK_ERRORS = [ + error for error in NETWORK_ERRORS if error is not requests.exceptions.ReadTimeout +] + def join_url(base: str, path: str) -> str: """ Join base URL and path properly, handling slashes appropriately. @@ -175,7 +182,9 @@ def get( headers=dict(response.headers) ) - @with_retry(max_retries=4, base_delay=1.0) + @with_retry( + max_retries=4, base_delay=1.0, network_errors=IDEMPOTENT_NETWORK_ERRORS + ) def post( self, path: str, @@ -200,7 +209,9 @@ def post( headers=dict(response.headers) ) - @with_retry(max_retries=4, base_delay=1.0) + @with_retry( + max_retries=4, base_delay=1.0, network_errors=IDEMPOTENT_NETWORK_ERRORS + ) def patch( self, path: str, @@ -225,7 +236,9 @@ def patch( headers=dict(response.headers) ) - @with_retry(max_retries=4, base_delay=1.0) + @with_retry( + max_retries=4, base_delay=1.0, network_errors=IDEMPOTENT_NETWORK_ERRORS + ) def delete( self, path: str, diff --git a/tests/test_infisical_requests.py b/tests/test_infisical_requests.py index 0c63322..4498d4f 100644 --- a/tests/test_infisical_requests.py +++ b/tests/test_infisical_requests.py @@ -1,6 +1,7 @@ from unittest import mock import pytest +import requests from infisical_sdk import InfisicalSDKClient from infisical_sdk.infisical_requests import InfisicalRequests @@ -29,3 +30,30 @@ def test_request_carries_configured_timeout(): def test_client_timeout_reaches_the_request(): with InfisicalSDKClient(host="https://example.com", timeout=5) as client: assert request_kwargs(client.api, "get")["timeout"] == 5 + + +def send_attempts(api, method, error): + with mock.patch.object(api.session, method, side_effect=error) as send: + with pytest.raises(type(error)): + getattr(api, method)("/path", dict) + return send.call_count + + +@pytest.mark.parametrize("method", ["post", "patch", "delete"]) +def test_read_timeout_is_not_replayed_on_writes(method): + api = InfisicalRequests(host="https://example.com") + + assert send_attempts(api, method, requests.exceptions.ReadTimeout()) == 1 + + +def test_read_timeout_is_retried_on_reads(): + api = InfisicalRequests(host="https://example.com") + + assert send_attempts(api, "get", requests.exceptions.ReadTimeout()) > 1 + + +@pytest.mark.parametrize("method", ["post", "patch", "delete"]) +def test_connect_timeout_is_still_retried_on_writes(method): + api = InfisicalRequests(host="https://example.com") + + assert send_attempts(api, method, requests.exceptions.ConnectTimeout()) > 1 From fb3b70b7df259af28fe97e9c93214095157f1261 Mon Sep 17 00:00:00 2001 From: codechrl Date: Mon, 7 Sep 2026 14:03:03 +0700 Subject: [PATCH 3/3] test: mock the retry backoff so the suite does not sleep The retry tests exercised the real exponential backoff, which added about a minute to the run for no added coverage. --- tests/test_infisical_requests.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_infisical_requests.py b/tests/test_infisical_requests.py index 4498d4f..0476e41 100644 --- a/tests/test_infisical_requests.py +++ b/tests/test_infisical_requests.py @@ -34,8 +34,9 @@ def test_client_timeout_reaches_the_request(): def send_attempts(api, method, error): with mock.patch.object(api.session, method, side_effect=error) as send: - with pytest.raises(type(error)): - getattr(api, method)("/path", dict) + with mock.patch("infisical_sdk.infisical_requests.time.sleep"): + with pytest.raises(type(error)): + getattr(api, method)("/path", dict) return send.call_count