ledgix-python 0.1.1__tar.gz → 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. {ledgix_python-0.1.1 → ledgix_python-0.1.2}/PKG-INFO +1 -1
  2. {ledgix_python-0.1.1 → ledgix_python-0.1.2}/pyproject.toml +1 -1
  3. {ledgix_python-0.1.1 → ledgix_python-0.1.2}/src/ledgix_python/client.py +103 -43
  4. {ledgix_python-0.1.1 → ledgix_python-0.1.2}/src/ledgix_python/config.py +6 -0
  5. {ledgix_python-0.1.1 → ledgix_python-0.1.2}/src/ledgix_python/enforce.py +15 -9
  6. {ledgix_python-0.1.1 → ledgix_python-0.1.2}/tests/conftest.py +17 -0
  7. {ledgix_python-0.1.1 → ledgix_python-0.1.2}/tests/test_client.py +94 -0
  8. {ledgix_python-0.1.1 → ledgix_python-0.1.2}/.gitignore +0 -0
  9. {ledgix_python-0.1.1 → ledgix_python-0.1.2}/README.md +0 -0
  10. {ledgix_python-0.1.1 → ledgix_python-0.1.2}/demo.py +0 -0
  11. {ledgix_python-0.1.1 → ledgix_python-0.1.2}/requirements.txt +0 -0
  12. {ledgix_python-0.1.1 → ledgix_python-0.1.2}/src/ledgix_python/__init__.py +0 -0
  13. {ledgix_python-0.1.1 → ledgix_python-0.1.2}/src/ledgix_python/adapters/__init__.py +0 -0
  14. {ledgix_python-0.1.1 → ledgix_python-0.1.2}/src/ledgix_python/adapters/crewai.py +0 -0
  15. {ledgix_python-0.1.1 → ledgix_python-0.1.2}/src/ledgix_python/adapters/langchain.py +0 -0
  16. {ledgix_python-0.1.1 → ledgix_python-0.1.2}/src/ledgix_python/adapters/llamaindex.py +0 -0
  17. {ledgix_python-0.1.1 → ledgix_python-0.1.2}/src/ledgix_python/exceptions.py +0 -0
  18. {ledgix_python-0.1.1 → ledgix_python-0.1.2}/src/ledgix_python/models.py +0 -0
  19. {ledgix_python-0.1.1 → ledgix_python-0.1.2}/tests/__init__.py +0 -0
  20. {ledgix_python-0.1.1 → ledgix_python-0.1.2}/tests/test_adapters.py +0 -0
  21. {ledgix_python-0.1.1 → ledgix_python-0.1.2}/tests/test_enforce.py +0 -0
  22. {ledgix_python-0.1.1 → ledgix_python-0.1.2}/tests/test_models.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ledgix-python
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Agent-agnostic compliance shim for SOX 404 policy enforcement via the ALCV Vault
5
5
  Project-URL: Homepage, https://github.com/ledgix-dev/python-sdk
6
6
  Project-URL: Documentation, https://docs.ledgix.dev
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ledgix-python"
7
- version = "0.1.1"
7
+ version = "0.1.2"
8
8
  description = "Agent-agnostic compliance shim for SOX 404 policy enforcement via the ALCV Vault"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -4,12 +4,16 @@
4
4
  from __future__ import annotations
5
5
 
6
6
  import json
7
+ import random
7
8
  import time
9
+ from collections.abc import Awaitable, Callable
8
10
  from typing import Any
9
11
 
10
12
  import httpx
11
13
  import jwt
12
14
 
15
+ _RETRYABLE_STATUS_CODES: frozenset[int] = frozenset({429, 500, 502, 503, 504})
16
+
13
17
  from .config import VaultConfig
14
18
  from .exceptions import (
15
19
  ClearanceDeniedError,
@@ -74,6 +78,66 @@ class LedgixClient:
74
78
  )
75
79
  return self._async_client
76
80
 
81
+ # ------------------------------------------------------------------
82
+ # Retry helpers
83
+ # ------------------------------------------------------------------
84
+
85
+ def _backoff_delay(self, attempt: int) -> float:
86
+ """Exponential backoff with full jitter, capped at 30 seconds."""
87
+ delay = min(30.0, self.config.retry_base_delay * (2 ** attempt))
88
+ return random.uniform(0.0, delay)
89
+
90
+ def _sync_retry(self, fn: Callable[[], httpx.Response]) -> httpx.Response:
91
+ """Execute an HTTP callable with retry and exponential backoff.
92
+
93
+ Retries on ``httpx.TransportError`` (network errors, timeouts) and on
94
+ retryable HTTP status codes (429, 5xx). Raises ``VaultConnectionError``
95
+ after all attempts are exhausted.
96
+ """
97
+ last_exc: httpx.TransportError | None = None
98
+ response: httpx.Response | None = None
99
+ for attempt in range(self.config.max_retries + 1):
100
+ try:
101
+ response = fn()
102
+ except httpx.TransportError as exc:
103
+ last_exc = exc
104
+ if attempt < self.config.max_retries:
105
+ time.sleep(self._backoff_delay(attempt))
106
+ continue
107
+ raise VaultConnectionError(str(exc)) from exc
108
+ if response.status_code in _RETRYABLE_STATUS_CODES and attempt < self.config.max_retries:
109
+ time.sleep(self._backoff_delay(attempt))
110
+ continue
111
+ return response
112
+ if last_exc is not None:
113
+ raise VaultConnectionError(str(last_exc)) from last_exc
114
+ assert response is not None
115
+ return response
116
+
117
+ async def _async_retry(self, fn: Callable[[], Awaitable[httpx.Response]]) -> httpx.Response:
118
+ """Async variant of ``_sync_retry``."""
119
+ import asyncio
120
+
121
+ last_exc: httpx.TransportError | None = None
122
+ response: httpx.Response | None = None
123
+ for attempt in range(self.config.max_retries + 1):
124
+ try:
125
+ response = await fn()
126
+ except httpx.TransportError as exc:
127
+ last_exc = exc
128
+ if attempt < self.config.max_retries:
129
+ await asyncio.sleep(self._backoff_delay(attempt))
130
+ continue
131
+ raise VaultConnectionError(str(exc)) from exc
132
+ if response.status_code in _RETRYABLE_STATUS_CODES and attempt < self.config.max_retries:
133
+ await asyncio.sleep(self._backoff_delay(attempt))
134
+ continue
135
+ return response
136
+ if last_exc is not None:
137
+ raise VaultConnectionError(str(last_exc)) from last_exc
138
+ assert response is not None
139
+ return response
140
+
77
141
  # ------------------------------------------------------------------
78
142
  # Clearance — sync
79
143
  # ------------------------------------------------------------------
@@ -86,13 +150,13 @@ class LedgixClient:
86
150
  VaultConnectionError: If the Vault is unreachable.
87
151
  """
88
152
  try:
89
- response = self._get_sync_client().post(
90
- "/request-clearance",
91
- content=request.model_dump_json(),
153
+ response = self._sync_retry(
154
+ lambda: self._get_sync_client().post(
155
+ "/request-clearance",
156
+ content=request.model_dump_json(),
157
+ )
92
158
  )
93
159
  response.raise_for_status()
94
- except httpx.ConnectError as exc:
95
- raise VaultConnectionError(str(exc)) from exc
96
160
  except httpx.HTTPStatusError as exc:
97
161
  raise VaultConnectionError(
98
162
  f"Vault returned HTTP {exc.response.status_code}: {exc.response.text}"
@@ -124,13 +188,13 @@ class LedgixClient:
124
188
  VaultConnectionError: If the Vault is unreachable.
125
189
  """
126
190
  try:
127
- response = await self._get_async_client().post(
128
- "/request-clearance",
129
- content=request.model_dump_json(),
191
+ response = await self._async_retry(
192
+ lambda: self._get_async_client().post(
193
+ "/request-clearance",
194
+ content=request.model_dump_json(),
195
+ )
130
196
  )
131
197
  response.raise_for_status()
132
- except httpx.ConnectError as exc:
133
- raise VaultConnectionError(str(exc)) from exc
134
198
  except httpx.HTTPStatusError as exc:
135
199
  raise VaultConnectionError(
136
200
  f"Vault returned HTTP {exc.response.status_code}: {exc.response.text}"
@@ -157,13 +221,13 @@ class LedgixClient:
157
221
  def register_policy(self, policy: PolicyRegistration) -> PolicyRegistrationResponse:
158
222
  """Register a policy with the Vault (sync)."""
159
223
  try:
160
- response = self._get_sync_client().post(
161
- "/register-policy",
162
- content=policy.model_dump_json(),
224
+ response = self._sync_retry(
225
+ lambda: self._get_sync_client().post(
226
+ "/register-policy",
227
+ content=policy.model_dump_json(),
228
+ )
163
229
  )
164
230
  response.raise_for_status()
165
- except httpx.ConnectError as exc:
166
- raise VaultConnectionError(str(exc)) from exc
167
231
  except httpx.HTTPStatusError as exc:
168
232
  raise PolicyRegistrationError(
169
233
  f"Vault returned HTTP {exc.response.status_code}: {exc.response.text}"
@@ -174,13 +238,13 @@ class LedgixClient:
174
238
  async def aregister_policy(self, policy: PolicyRegistration) -> PolicyRegistrationResponse:
175
239
  """Register a policy with the Vault (async)."""
176
240
  try:
177
- response = await self._get_async_client().post(
178
- "/register-policy",
179
- content=policy.model_dump_json(),
241
+ response = await self._async_retry(
242
+ lambda: self._get_async_client().post(
243
+ "/register-policy",
244
+ content=policy.model_dump_json(),
245
+ )
180
246
  )
181
247
  response.raise_for_status()
182
- except httpx.ConnectError as exc:
183
- raise VaultConnectionError(str(exc)) from exc
184
248
  except httpx.HTTPStatusError as exc:
185
249
  raise PolicyRegistrationError(
186
250
  f"Vault returned HTTP {exc.response.status_code}: {exc.response.text}"
@@ -225,10 +289,10 @@ class LedgixClient:
225
289
  def fetch_jwks(self) -> dict[str, Any]:
226
290
  """Fetch the Vault's JWKS (JSON Web Key Set) for token verification (sync)."""
227
291
  try:
228
- response = self._get_sync_client().get("/.well-known/jwks.json")
292
+ response = self._sync_retry(
293
+ lambda: self._get_sync_client().get("/.well-known/jwks.json")
294
+ )
229
295
  response.raise_for_status()
230
- except httpx.ConnectError as exc:
231
- raise VaultConnectionError(str(exc)) from exc
232
296
  except httpx.HTTPStatusError as exc:
233
297
  raise VaultConnectionError(
234
298
  f"Failed to fetch JWKS: HTTP {exc.response.status_code}"
@@ -240,10 +304,10 @@ class LedgixClient:
240
304
  async def afetch_jwks(self) -> dict[str, Any]:
241
305
  """Fetch the Vault's JWKS for token verification (async)."""
242
306
  try:
243
- response = await self._get_async_client().get("/.well-known/jwks.json")
307
+ response = await self._async_retry(
308
+ lambda: self._get_async_client().get("/.well-known/jwks.json")
309
+ )
244
310
  response.raise_for_status()
245
- except httpx.ConnectError as exc:
246
- raise VaultConnectionError(str(exc)) from exc
247
311
  except httpx.HTTPStatusError as exc:
248
312
  raise VaultConnectionError(
249
313
  f"Failed to fetch JWKS: HTTP {exc.response.status_code}"
@@ -261,37 +325,31 @@ class LedgixClient:
261
325
  TokenVerificationError: If the token is invalid, expired, or
262
326
  the JWKS cannot be fetched.
263
327
  """
264
- return self._verify_token_internal(token, sync=True)
328
+ if self._jwks_cache is None:
329
+ self.fetch_jwks()
330
+ return self._decode_token(token)
265
331
 
266
332
  async def averify_token(self, token: str) -> dict[str, Any]:
267
- """Verify an A-JWT using the Vault's public key (async)."""
268
- return self._verify_token_internal(token, sync=False)
333
+ """Verify an A-JWT using the Vault's public key (async).
269
334
 
270
- def _verify_token_internal(self, token: str, sync: bool = True) -> dict[str, Any]:
271
- """Shared verification logic.
272
-
273
- Note: For async callers this is still synchronous internally
274
- because PyJWT is sync. The async variant pre-fetches JWKS
275
- asynchronously before calling this.
335
+ Raises:
336
+ TokenVerificationError: If the token is invalid, expired, or
337
+ the JWKS cannot be fetched.
276
338
  """
277
339
  if self._jwks_cache is None:
278
- if sync:
279
- self.fetch_jwks()
280
- else:
281
- # In async context, caller must have pre-fetched JWKS.
282
- # Fall back to sync fetch if cache is empty.
283
- self.fetch_jwks()
340
+ await self.afetch_jwks()
341
+ return self._decode_token(token)
284
342
 
343
+ def _decode_token(self, token: str) -> dict[str, Any]:
344
+ """Verify an A-JWT against the cached JWKS. JWKS must already be populated."""
285
345
  if not self._jwks_cache:
286
346
  raise TokenVerificationError("No JWKS available from Vault")
287
347
 
288
348
  try:
289
- # Extract the first key from the JWKS
290
349
  jwks = self._jwks_cache
291
350
  if "keys" not in jwks or not jwks["keys"]:
292
351
  raise TokenVerificationError("JWKS contains no keys")
293
352
 
294
- # Build a PyJWT key from the JWK
295
353
  key_data = jwks["keys"][0]
296
354
  public_key = jwt.algorithms.OKPAlgorithm.from_jwk(json.dumps(key_data))
297
355
 
@@ -307,6 +365,8 @@ class LedgixClient:
307
365
  raise TokenVerificationError("Invalid A-JWT: unexpected subject")
308
366
  return decoded
309
367
 
368
+ except TokenVerificationError:
369
+ raise
310
370
  except jwt.ExpiredSignatureError as exc:
311
371
  raise TokenVerificationError("A-JWT has expired") from exc
312
372
  except jwt.InvalidTokenError as exc:
@@ -49,3 +49,9 @@ class VaultConfig(BaseSettings):
49
49
 
50
50
  review_timeout: float = 300.0
51
51
  """Maximum wait time in seconds for a pending manual review decision."""
52
+
53
+ max_retries: int = 3
54
+ """Number of retry attempts for transient failures (connection errors, 5xx responses)."""
55
+
56
+ retry_base_delay: float = 0.5
57
+ """Base delay in seconds for exponential backoff between retries (full jitter applied)."""
@@ -148,17 +148,23 @@ def _extract_tool_args(
148
148
  args: tuple[Any, ...],
149
149
  kwargs: dict[str, Any],
150
150
  ) -> dict[str, Any]:
151
- """Best-effort extraction of function arguments as a dict for the clearance request."""
151
+ """Best-effort extraction of function arguments as a dict for the clearance request.
152
+
153
+ Skips ``self``, private parameters (prefixed with ``_``), ``*args``, and
154
+ ``**kwargs`` captures so only named, user-visible parameters are included.
155
+ """
152
156
  try:
153
157
  sig = inspect.signature(func)
154
158
  bound = sig.bind_partial(*args, **kwargs)
155
159
  bound.apply_defaults()
156
- # Filter out internal kwargs
157
- return {
158
- k: v
159
- for k, v in bound.arguments.items()
160
- if not k.startswith("_") and k != "self"
161
- }
162
- except Exception:
163
- # Fallback: just return kwargs
160
+ result: dict[str, Any] = {}
161
+ for name, value in bound.arguments.items():
162
+ if name.startswith("_") or name == "self":
163
+ continue
164
+ param = sig.parameters[name]
165
+ if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
166
+ continue
167
+ result[name] = value
168
+ return result
169
+ except (TypeError, ValueError):
164
170
  return {k: v for k, v in kwargs.items() if not k.startswith("_")}
@@ -88,6 +88,7 @@ def vault_config() -> VaultConfig:
88
88
  jwt_audience="ledgix-sdk",
89
89
  agent_id="test-agent",
90
90
  session_id="test-session",
91
+ max_retries=0,
91
92
  )
92
93
 
93
94
 
@@ -102,6 +103,22 @@ def vault_config_with_jwt() -> VaultConfig:
102
103
  jwt_audience="ledgix-sdk",
103
104
  agent_id="test-agent",
104
105
  session_id="test-session",
106
+ max_retries=0,
107
+ )
108
+
109
+
110
+ @pytest.fixture
111
+ def vault_config_retry() -> VaultConfig:
112
+ """Config with retries enabled and zero backoff delay for fast retry tests."""
113
+ return VaultConfig(
114
+ vault_url="https://vault.test",
115
+ vault_api_key="test-api-key",
116
+ vault_timeout=5.0,
117
+ verify_jwt=False,
118
+ agent_id="test-agent",
119
+ session_id="test-session",
120
+ max_retries=2,
121
+ retry_base_delay=0.0,
105
122
  )
106
123
 
107
124
 
@@ -314,6 +314,100 @@ class TestTokenVerification:
314
314
  # ──────────────────────────────────────────────────────────────────────
315
315
 
316
316
 
317
+ # ──────────────────────────────────────────────────────────────────────
318
+ # Retry behaviour
319
+ # ──────────────────────────────────────────────────────────────────────
320
+
321
+
322
+ class TestRetry:
323
+ """Tests for automatic retry with exponential backoff."""
324
+
325
+ @respx.mock
326
+ def test_retries_on_connection_error_then_succeeds(
327
+ self, vault_config_retry: VaultConfig, approved_response: dict
328
+ ):
329
+ client = LedgixClient(config=vault_config_retry)
330
+ route = respx.post("https://vault.test/request-clearance").mock(
331
+ side_effect=[
332
+ httpx.ConnectError("Connection refused"),
333
+ httpx.ConnectError("Connection refused"),
334
+ Response(200, json=approved_response),
335
+ ]
336
+ )
337
+
338
+ request = ClearanceRequest(tool_name="stripe_refund", tool_args={"amount": 45})
339
+ result = client.request_clearance(request)
340
+
341
+ assert result.approved is True
342
+ assert route.call_count == 3
343
+ client.close()
344
+
345
+ @respx.mock
346
+ def test_retries_on_5xx_then_succeeds(
347
+ self, vault_config_retry: VaultConfig, approved_response: dict
348
+ ):
349
+ client = LedgixClient(config=vault_config_retry)
350
+ respx.post("https://vault.test/request-clearance").mock(
351
+ side_effect=[
352
+ Response(503, text="Service Unavailable"),
353
+ Response(503, text="Service Unavailable"),
354
+ Response(200, json=approved_response),
355
+ ]
356
+ )
357
+
358
+ request = ClearanceRequest(tool_name="stripe_refund", tool_args={"amount": 45})
359
+ result = client.request_clearance(request)
360
+
361
+ assert result.approved is True
362
+ client.close()
363
+
364
+ @respx.mock
365
+ def test_raises_after_exhausting_retries(self, vault_config_retry: VaultConfig):
366
+ client = LedgixClient(config=vault_config_retry)
367
+ respx.post("https://vault.test/request-clearance").mock(
368
+ side_effect=httpx.ConnectError("Connection refused")
369
+ )
370
+
371
+ request = ClearanceRequest(tool_name="stripe_refund", tool_args={"amount": 45})
372
+ with pytest.raises(VaultConnectionError):
373
+ client.request_clearance(request)
374
+ client.close()
375
+
376
+ @respx.mock
377
+ def test_does_not_retry_on_4xx(self, vault_config_retry: VaultConfig):
378
+ client = LedgixClient(config=vault_config_retry)
379
+ route = respx.post("https://vault.test/request-clearance").mock(
380
+ return_value=Response(400, text="Bad Request")
381
+ )
382
+
383
+ request = ClearanceRequest(tool_name="stripe_refund", tool_args={"amount": 45})
384
+ with pytest.raises(VaultConnectionError):
385
+ client.request_clearance(request)
386
+
387
+ # 400 is not retryable — should only be called once
388
+ assert route.call_count == 1
389
+ client.close()
390
+
391
+ @respx.mock
392
+ @pytest.mark.asyncio
393
+ async def test_async_retries_on_connection_error(
394
+ self, vault_config_retry: VaultConfig, approved_response: dict
395
+ ):
396
+ client = LedgixClient(config=vault_config_retry)
397
+ respx.post("https://vault.test/request-clearance").mock(
398
+ side_effect=[
399
+ httpx.ConnectError("Connection refused"),
400
+ Response(200, json=approved_response),
401
+ ]
402
+ )
403
+
404
+ request = ClearanceRequest(tool_name="stripe_refund", tool_args={"amount": 45})
405
+ result = await client.arequest_clearance(request)
406
+
407
+ assert result.approved is True
408
+ await client.aclose()
409
+
410
+
317
411
  class TestClientLifecycle:
318
412
  """Tests for context manager and close behavior."""
319
413
 
File without changes
File without changes
File without changes