From e2962ff59f84aaeca3c1b661b08281f545f0a6fa Mon Sep 17 00:00:00 2001 From: Peter Sprygada Date: Fri, 12 Dec 2025 07:46:07 -0500 Subject: [PATCH] feat: add support for connection ttl - Update connection.py, gateway.py, and platform.py with new parameter name - Change log message from "Auth timeout exceeded" to "Auth TTL exceeded" - Update all docstrings to reflect "time to live" terminology - Rename test functions and update assertions to use ttl instead of auth_timeout - Add 6 comprehensive test cases for TTL reauthentication behavior - Add detailed TTL documentation to docs/development.md with usage examples - Document TTL best practices, common values, and troubleshooting --- docs/development.md | 228 +++++++++++++++++++++++++++++ src/ipsdk/connection.py | 55 +++++++ src/ipsdk/gateway.py | 5 + src/ipsdk/platform.py | 5 + tests/test_connection.py | 301 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 594 insertions(+) diff --git a/docs/development.md b/docs/development.md index 59b8008..8f9b723 100644 --- a/docs/development.md +++ b/docs/development.md @@ -454,6 +454,234 @@ This helps identify performance bottlenecks by showing exactly which functions a 4. **Review timing data** - Use logs to identify slow operations during development 5. **Combine with other logging** - Mix with `debug()`, `info()`, etc. for comprehensive visibility +## Authentication and Session Management + +### Time to Live (TTL) for Authentication + +The SDK supports automatic reauthentication through the `ttl` (time to live) parameter. This feature forces the SDK to reauthenticate after a specified period, which is useful for long-running applications or when working with authentication tokens that expire. + +#### What is TTL? + +TTL (time to live) defines how long an authentication session remains valid before forcing reauthentication. When the TTL expires: +1. The SDK automatically detects the timeout on the next API request +2. The authentication token is cleared +3. A new authentication request is made before proceeding +4. The timestamp is reset for the next TTL period + +#### When to Use TTL + +Use the `ttl` parameter when: +- **Long-running applications**: Services that run for extended periods (hours or days) +- **Token expiration**: Your authentication tokens expire after a certain time +- **Security requirements**: Your organization requires periodic reauthentication +- **Session refresh**: You want to ensure fresh credentials are used regularly + +#### Default Behavior + +By default, `ttl` is set to `0`, which means reauthentication is **disabled**. The SDK will authenticate once and reuse the same token/session for the lifetime of the connection object. + +#### Basic Usage + +```python +import ipsdk + +# Create a Platform connection with 30-minute TTL (1800 seconds) +platform = ipsdk.platform_factory( + host="platform.example.com", + user="admin", + password="password", + ttl=1800 # Force reauthentication every 30 minutes +) + +# Create a Gateway connection with 1-hour TTL (3600 seconds) +gateway = ipsdk.gateway_factory( + host="gateway.example.com", + user="admin@itential", + password="password", + ttl=3600 # Force reauthentication every hour +) +``` + +#### How It Works + +```python +import time +import ipsdk + +# Create connection with 10-second TTL for demonstration +platform = ipsdk.platform_factory( + host="platform.example.com", + user="admin", + password="password", + ttl=10 # Very short TTL for testing +) + +# First request - authenticates +response = platform.get("/api/v2.0/workflows") +print("First request successful") + +# Wait 5 seconds - within TTL window +time.sleep(5) +response = platform.get("/api/v2.0/workflows") +print("Second request - reused existing authentication") + +# Wait another 6 seconds - total 11 seconds, exceeds TTL +time.sleep(6) +response = platform.get("/api/v2.0/workflows") +print("Third request - automatically reauthenticated") +``` + +#### TTL with Different Authentication Methods + +The TTL feature works with all supported authentication methods: + +**OAuth (Platform only):** +```python +platform = ipsdk.platform_factory( + host="platform.example.com", + client_id="your_client_id", + client_secret="your_client_secret", + ttl=1800 # Reauthenticate every 30 minutes +) +``` + +**Basic Authentication (Platform and Gateway):** +```python +# Platform with basic auth +platform = ipsdk.platform_factory( + host="platform.example.com", + user="admin", + password="password", + ttl=3600 # Reauthenticate every hour +) + +# Gateway with basic auth +gateway = ipsdk.gateway_factory( + host="gateway.example.com", + user="admin@itential", + password="password", + ttl=3600 # Reauthenticate every hour +) +``` + +#### TTL with Async Connections + +The TTL feature works identically with async connections: + +```python +import asyncio +import ipsdk + +async def main(): + # Create async connection with TTL + platform = ipsdk.platform_factory( + host="platform.example.com", + user="admin", + password="password", + ttl=1800, # 30 minutes + want_async=True + ) + + # First request - authenticates + response = await platform.get("/api/v2.0/workflows") + + # Subsequent requests within TTL window reuse authentication + response = await platform.get("/api/v2.0/devices") + + # After TTL expires, next request will automatically reauthenticate + await asyncio.sleep(1801) + response = await platform.get("/api/v2.0/workflows") + +asyncio.run(main()) +``` + +#### Thread Safety + +The SDK's TTL implementation is thread-safe: +- Synchronous connections use `threading.Lock()` +- Asynchronous connections use `asyncio.Lock()` +- Multiple threads/tasks attempting simultaneous requests will only trigger one reauthentication + +#### Logging TTL Activity + +Enable logging to monitor TTL-related reauthentication: + +```python +import ipsdk + +# Enable INFO level logging to see TTL messages +ipsdk.logging.set_level(ipsdk.logging.INFO) + +platform = ipsdk.platform_factory( + host="platform.example.com", + user="admin", + password="password", + ttl=1800 +) + +# When TTL expires, you'll see log messages like: +# Auth TTL exceeded (1801.2s >= 1800s) +# Forcing reauthentication due to timeout +``` + +#### Best Practices + +1. **Match token expiration**: Set TTL slightly lower than your token expiration time + ```python + # If tokens expire after 1 hour, set TTL to 55 minutes + ttl=3300 # 55 minutes + ``` + +2. **Use reasonable intervals**: Don't set TTL too low (causes unnecessary authentication overhead) + ```python + # Good: 30 minutes to 1 hour for most applications + ttl=1800 # 30 minutes + + # Avoid: Very short intervals (causes performance issues) + ttl=60 # Not recommended unless required + ``` + +3. **Consider your workload**: Balance security needs with authentication overhead + - High-frequency API calls: Use longer TTL (1+ hour) + - Low-frequency periodic jobs: Use shorter TTL (15-30 minutes) + +4. **Disable for short scripts**: Set `ttl=0` (default) for scripts that complete quickly + ```python + # Quick data extraction script - no TTL needed + platform = ipsdk.platform_factory( + host="platform.example.com", + user="admin", + password="password" + # ttl=0 is the default - no need to specify + ) + ``` + +5. **Test TTL behavior**: Use short TTL values during development to verify reauthentication works correctly + +#### Common TTL Values + +| Duration | Seconds | Use Case | +|----------|---------|----------| +| 15 minutes | 900 | High-security environments, frequent token rotation | +| 30 minutes | 1800 | Balanced security and performance, recommended default | +| 1 hour | 3600 | Long-running applications with stable tokens | +| 2 hours | 7200 | Low-security environments, infrequent authentication | + +#### Troubleshooting + +**Issue: Frequent authentication errors** +- Your TTL may be longer than your token expiration time +- Solution: Reduce TTL to be shorter than token lifetime + +**Issue: Too many authentication requests** +- Your TTL is too short for your usage pattern +- Solution: Increase TTL to reduce authentication overhead + +**Issue: Reauthentication not happening** +- Verify TTL is set to a non-zero value +- Check that enough time has passed between requests +- Enable logging to see TTL status messages + ## Documentation Standards All code in the SDK follows strict documentation standards: diff --git a/src/ipsdk/connection.py b/src/ipsdk/connection.py index eeff902..80b715a 100644 --- a/src/ipsdk/connection.py +++ b/src/ipsdk/connection.py @@ -129,6 +129,7 @@ async def fetch_devices(): import abc import asyncio import threading +import time import urllib.parse from typing import Any @@ -158,6 +159,7 @@ def __init__( client_id: str | None = None, client_secret: str | None = None, timeout: int = 30, + ttl: int = 0, ) -> None: """Initialize the base connection class. @@ -178,6 +180,8 @@ def __init__( client_id: Client ID for OAuth authentication. Defaults to None. client_secret: Client secret for OAuth authentication. Defaults to None. timeout: Request timeout in seconds. Defaults to 30. + ttl: Time to live in seconds before forcing reauthentication. If 0, + reauthentication is disabled. Defaults to 0. Returns: None @@ -195,6 +199,8 @@ def __init__( self.authenticated = False self._auth_lock: Any | None = None + self._auth_timestamp: float | None = None + self.ttl = ttl self.client = self.__init_client__( base_url=self._make_base_url(host, port, base_path, use_tls), @@ -344,6 +350,39 @@ def _validate_request_args( msg = "path must be of type `str`" raise exceptions.IpsdkError(msg) + @logging.trace + def _needs_reauthentication(self) -> bool: + """Check if reauthentication is needed based on timeout. + + Determines whether the connection needs to reauthenticate by checking + if the ttl (time to live) has been exceeded since the last authentication. + If ttl is 0 (disabled) or no authentication has occurred yet, + returns False. + + Args: + None + + Returns: + bool: True if reauthentication is needed, False otherwise. + + Raises: + None + """ + if self.ttl <= 0: + return False + + if self._auth_timestamp is None: + return False + + elapsed = time.time() - self._auth_timestamp + if elapsed >= self.ttl: + logging.info( + f"Auth TTL exceeded ({elapsed:.1f}s >= {self.ttl}s)" + ) + return True + + return False + @abc.abstractmethod def __init_client__( self, base_url: str | None = None, verify: bool = True, timeout: int = 30 @@ -418,6 +457,7 @@ def _send_request( Automatically handles authentication on first request. Sets Content-Type and Accept headers to application/json when JSON body is provided. + Supports automatic reauthentication based on ttl setting. Args: method: HTTP method for the request. @@ -433,12 +473,19 @@ def _send_request( RequestError: Network or connection errors occurred. HTTPStatusError: Server returned an HTTP error status (4xx, 5xx). """ + # Check if reauthentication is needed due to timeout + if self._needs_reauthentication(): + logging.info("Forcing reauthentication due to timeout") + self.authenticated = False + self.token = None + if self.authenticated is False: assert self._auth_lock is not None with self._auth_lock: if self.authenticated is False: self.authenticate() self.authenticated = True + self._auth_timestamp = time.time() request = self._build_request( method=method, @@ -628,6 +675,7 @@ async def _send_request( Automatically handles authentication on first request. Sets Content-Type and Accept headers to application/json when JSON body is provided. + Supports automatic reauthentication based on ttl setting. Args: method: HTTP method for the request. @@ -643,12 +691,19 @@ async def _send_request( RequestError: Network or connection errors occurred. HTTPStatusError: Server returned an HTTP error status (4xx, 5xx). """ + # Check if reauthentication is needed due to timeout + if self._needs_reauthentication(): + logging.info("Forcing reauthentication due to timeout") + self.authenticated = False + self.token = None + if self.authenticated is False: assert self._auth_lock is not None async with self._auth_lock: if self.authenticated is False: await self.authenticate() self.authenticated = True + self._auth_timestamp = time.time() request = self._build_request( method=method, diff --git a/src/ipsdk/gateway.py b/src/ipsdk/gateway.py index ad025a5..0f29981 100644 --- a/src/ipsdk/gateway.py +++ b/src/ipsdk/gateway.py @@ -290,6 +290,7 @@ def gateway_factory( user: str = "admin@itential", password: str = "admin", timeout: int = 30, + ttl: int = 0, want_async: bool = False, ) -> Any: """Create a new instance of a Gateway connection. @@ -322,6 +323,9 @@ def gateway_factory( timeout (int): Timeout for the connection, in seconds. + ttl (int): Time to live in seconds before forcing reauthentication. If 0, + reauthentication is disabled. The default value is `0`. + want_async (bool): When set to True, the factory function will return an async connection object and when set to False the factory will return a connection object. @@ -338,5 +342,6 @@ def gateway_factory( user=user, password=password, timeout=timeout, + ttl=ttl, base_path="/api/v2.0", ) diff --git a/src/ipsdk/platform.py b/src/ipsdk/platform.py index 1af2feb..cf450fc 100644 --- a/src/ipsdk/platform.py +++ b/src/ipsdk/platform.py @@ -545,6 +545,7 @@ def platform_factory( client_id: str | None = None, client_secret: str | None = None, timeout: int = 30, + ttl: int = 0, want_async: bool = False, ) -> Any: """ @@ -587,6 +588,9 @@ def platform_factory( timeout (int): Configures the timeout value for requests sent to the server. The default value for timeout is `30`. + ttl (int): Time to live in seconds before forcing reauthentication. If 0, + reauthentication is disabled. The default value is `0`. + want_async (bool): When set to True, the factory function will return an async connection object and when set to False the factory will return a connection object. @@ -605,4 +609,5 @@ def platform_factory( client_id=client_id, client_secret=client_secret, timeout=timeout, + ttl=ttl, ) diff --git a/tests/test_connection.py b/tests/test_connection.py index d78a3ba..1388264 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -2,6 +2,7 @@ # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) import json +import time from unittest.mock import AsyncMock from unittest.mock import Mock @@ -1539,3 +1540,303 @@ async def test_async_connection_http_methods_delegate_to_send_request(): conn._send_request.assert_called_with( HTTPMethod.PATCH, path="/test", params=None, json={"d": "4"} ) + + +# --------- Reauthentication Tests --------- + + +def test_connection_ttl_defaults_to_zero(): + """Test that ttl defaults to 0 (disabled).""" + with patch.object(ConnectionBase, "__init_client__") as mock_init: + mock_init.return_value = Mock(headers={}) + conn = ConnectionBase("example.com") + assert conn.ttl == 0 + + +def test_connection_ttl_can_be_set(): + """Test that ttl can be set during initialization.""" + with patch.object(ConnectionBase, "__init_client__") as mock_init: + mock_init.return_value = Mock(headers={}) + conn = ConnectionBase("example.com", ttl=1800) + assert conn.ttl == 1800 + + +def test_needs_reauthentication_returns_false_when_disabled(): + """Test that _needs_reauthentication returns False when ttl is 0.""" + with patch.object(ConnectionBase, "__init_client__") as mock_init: + mock_init.return_value = Mock(headers={}) + conn = ConnectionBase("example.com", ttl=0) + conn._auth_timestamp = 1000.0 + assert conn._needs_reauthentication() is False + + +def test_needs_reauthentication_returns_false_when_no_auth_yet(): + """Test that _needs_reauthentication returns False when no auth has occurred.""" + with patch.object(ConnectionBase, "__init_client__") as mock_init: + mock_init.return_value = Mock(headers={}) + conn = ConnectionBase("example.com", ttl=1800) + conn._auth_timestamp = None + assert conn._needs_reauthentication() is False + + +def test_needs_reauthentication_returns_true_when_timeout_exceeded(): + """Test that _needs_reauthentication returns True when timeout has passed.""" + with patch.object(ConnectionBase, "__init_client__") as mock_init: + mock_init.return_value = Mock(headers={}) + conn = ConnectionBase("example.com", ttl=10) + + # Set timestamp to 15 seconds ago + conn._auth_timestamp = time.time() - 15 + assert conn._needs_reauthentication() is True + + +def test_needs_reauthentication_returns_false_when_timeout_not_exceeded(): + """Test that _needs_reauthentication returns False when timeout has not passed.""" + with patch.object(ConnectionBase, "__init_client__") as mock_init: + mock_init.return_value = Mock(headers={}) + conn = ConnectionBase("example.com", ttl=10) + + # Set timestamp to 5 seconds ago + conn._auth_timestamp = time.time() - 5 + assert conn._needs_reauthentication() is False + + +def test_connection_forces_reauthentication_when_ttl_exceeded(): + """Test that Connection reauthenticates when TTL has expired during a request.""" + + class TestConnection(Connection): + def authenticate(self) -> None: + self.token = "test-token" + + with patch.object(TestConnection, "__init_client__") as mock_init: + mock_client = Mock(spec=httpx.Client) + mock_init.return_value = mock_client + mock_client.headers = {} + + conn = TestConnection("example.com", ttl=10) + + # First request - authenticate normally + mock_response = Mock() + mock_response.status_code = 200 + mock_response.headers = {} + mock_response.content = b'{"success": true}' + mock_client.send.return_value = mock_response + + response = conn.get("/test") + assert response is not None + assert conn.authenticated is True + + # Simulate TTL expiration by setting timestamp to 15 seconds ago + conn._auth_timestamp = time.time() - 15 + + # Second request - should reauthenticate + response = conn.get("/test") + assert response is not None + assert conn.authenticated is True + # Token should be refreshed + assert conn.token == "test-token" + + +@pytest.mark.asyncio +async def test_async_connection_forces_reauthentication_when_ttl_exceeded(): + """Test AsyncConnection reauthenticates when TTL has expired during request.""" + + class TestAsyncConnection(AsyncConnection): + async def authenticate(self) -> None: + self.token = "test-token-async" + + with patch.object(TestAsyncConnection, "__init_client__") as mock_init: + mock_client = AsyncMock(spec=httpx.AsyncClient) + mock_init.return_value = mock_client + mock_client.headers = {} + + conn = TestAsyncConnection("example.com", ttl=10) + + # First request - authenticate normally + mock_response = Mock() + mock_response.status_code = 200 + mock_response.headers = {} + mock_response.content = b'{"success": true}' + mock_client.send.return_value = mock_response + + response = await conn.get("/test") + assert response is not None + assert conn.authenticated is True + + # Simulate TTL expiration by setting timestamp to 15 seconds ago + conn._auth_timestamp = time.time() - 15 + + # Second request - should reauthenticate + response = await conn.get("/test") + assert response is not None + assert conn.authenticated is True + # Token should be refreshed + assert conn.token == "test-token-async" + + +def test_connection_reauthentication_resets_token(): + """Test that reauthentication clears the old token before authenticating.""" + + class TestConnection(Connection): + def authenticate(self) -> None: + self.token = "new-token" + + with patch.object(TestConnection, "__init_client__") as mock_init: + mock_client = Mock(spec=httpx.Client) + mock_init.return_value = mock_client + mock_client.headers = {} + + conn = TestConnection("example.com", ttl=1) + + # Set up a previous authentication + conn.authenticated = True + conn.token = "old-token" + conn._auth_timestamp = time.time() - 2 # Expired + + mock_response = Mock() + mock_response.status_code = 200 + mock_response.headers = {} + mock_response.content = b'{"success": true}' + mock_client.send.return_value = mock_response + + # Make request - should reauthenticate + response = conn.get("/test") + + assert response is not None + assert conn.token == "new-token" + assert conn.authenticated is True + + +@pytest.mark.asyncio +async def test_async_connection_reauthentication_resets_token(): + """Test that async reauthentication clears the old token before authenticating.""" + + class TestAsyncConnection(AsyncConnection): + async def authenticate(self) -> None: + self.token = "new-token-async" + + with patch.object(TestAsyncConnection, "__init_client__") as mock_init: + mock_client = AsyncMock(spec=httpx.AsyncClient) + mock_init.return_value = mock_client + mock_client.headers = {} + + conn = TestAsyncConnection("example.com", ttl=1) + + # Set up a previous authentication + conn.authenticated = True + conn.token = "old-token-async" + conn._auth_timestamp = time.time() - 2 # Expired + + mock_response = Mock() + mock_response.status_code = 200 + mock_response.headers = {} + mock_response.content = b'{"success": true}' + mock_client.send.return_value = mock_response + + # Make request - should reauthenticate + response = await conn.get("/test") + + assert response is not None + assert conn.token == "new-token-async" + assert conn.authenticated is True + + +def test_connection_ttl_reauthentication_with_multiple_requests(): + """Test that TTL reauthentication works correctly across multiple requests.""" + + class TestConnection(Connection): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.auth_count = 0 + + def authenticate(self) -> None: + self.auth_count += 1 + self.token = f"token-{self.auth_count}" + + with patch.object(TestConnection, "__init_client__") as mock_init: + mock_client = Mock(spec=httpx.Client) + mock_init.return_value = mock_client + mock_client.headers = {} + + conn = TestConnection("example.com", ttl=5) + + mock_response = Mock() + mock_response.status_code = 200 + mock_response.headers = {} + mock_response.content = b'{"success": true}' + mock_client.send.return_value = mock_response + + # First request - initial authentication + conn.get("/test1") + assert conn.auth_count == 1 + assert conn.token == "token-1" + + # Second request within TTL - no reauthentication + conn.get("/test2") + assert conn.auth_count == 1 + assert conn.token == "token-1" + + # Expire TTL + conn._auth_timestamp = time.time() - 6 + + # Third request - should reauthenticate + conn.get("/test3") + assert conn.auth_count == 2 + assert conn.token == "token-2" + + # Fourth request within new TTL - no reauthentication + conn.get("/test4") + assert conn.auth_count == 2 + assert conn.token == "token-2" + + +@pytest.mark.asyncio +async def test_async_connection_ttl_reauthentication_with_multiple_requests(): + """Test that async TTL reauthentication works correctly across multiple requests.""" + + class TestAsyncConnection(AsyncConnection): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.auth_count = 0 + + async def authenticate(self) -> None: + self.auth_count += 1 + self.token = f"token-async-{self.auth_count}" + + with patch.object(TestAsyncConnection, "__init_client__") as mock_init: + mock_client = AsyncMock(spec=httpx.AsyncClient) + mock_init.return_value = mock_client + mock_client.headers = {} + + conn = TestAsyncConnection("example.com", ttl=5) + + mock_response = Mock() + mock_response.status_code = 200 + mock_response.headers = {} + mock_response.content = b'{"success": true}' + mock_client.send.return_value = mock_response + + # First request - initial authentication + await conn.get("/test1") + assert conn.auth_count == 1 + assert conn.token == "token-async-1" + + # Second request within TTL - no reauthentication + await conn.get("/test2") + assert conn.auth_count == 1 + assert conn.token == "token-async-1" + + # Expire TTL + conn._auth_timestamp = time.time() - 6 + + # Third request - should reauthenticate + await conn.get("/test3") + assert conn.auth_count == 2 + assert conn.token == "token-async-2" + + # Fourth request within new TTL - no reauthentication + await conn.get("/test4") + assert conn.auth_count == 2 + assert conn.token == "token-async-2" + +