Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions infisical_sdk/client.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import Optional

from .infisical_requests import InfisicalRequests

from infisical_sdk.resources import Auth
Expand All @@ -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)
Expand Down
30 changes: 22 additions & 8 deletions infisical_sdk/infisical_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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)
Comment thread
codechrl marked this conversation as resolved.
Resolved
data = self._handle_response(response)

parsed_data = model.from_dict(data) if hasattr(model, 'from_dict') else data
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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
Expand Down
Empty file added tests/__init__.py
Empty file.
60 changes: 60 additions & 0 deletions tests/test_infisical_requests.py
Original file line number Diff line number Diff line change
@@ -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
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved