diff --git a/infisical_sdk/client.py b/infisical_sdk/client.py index ce21277..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,19 +11,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: 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 the connection and for the response, applied to each attempt. 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..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. @@ -105,9 +112,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 +171,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 @@ -174,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, @@ -188,7 +198,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 @@ -199,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, @@ -213,7 +225,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 @@ -224,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, @@ -238,7 +252,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..0476e41 --- /dev/null +++ b/tests/test_infisical_requests.py @@ -0,0 +1,60 @@ +from unittest import mock + +import pytest +import requests + +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 + + +def send_attempts(api, method, error): + with mock.patch.object(api.session, method, side_effect=error) as send: + with mock.patch("infisical_sdk.infisical_requests.time.sleep"): + 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