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.
- {ledgix_python-0.1.1 → ledgix_python-0.1.2}/PKG-INFO +1 -1
- {ledgix_python-0.1.1 → ledgix_python-0.1.2}/pyproject.toml +1 -1
- {ledgix_python-0.1.1 → ledgix_python-0.1.2}/src/ledgix_python/client.py +103 -43
- {ledgix_python-0.1.1 → ledgix_python-0.1.2}/src/ledgix_python/config.py +6 -0
- {ledgix_python-0.1.1 → ledgix_python-0.1.2}/src/ledgix_python/enforce.py +15 -9
- {ledgix_python-0.1.1 → ledgix_python-0.1.2}/tests/conftest.py +17 -0
- {ledgix_python-0.1.1 → ledgix_python-0.1.2}/tests/test_client.py +94 -0
- {ledgix_python-0.1.1 → ledgix_python-0.1.2}/.gitignore +0 -0
- {ledgix_python-0.1.1 → ledgix_python-0.1.2}/README.md +0 -0
- {ledgix_python-0.1.1 → ledgix_python-0.1.2}/demo.py +0 -0
- {ledgix_python-0.1.1 → ledgix_python-0.1.2}/requirements.txt +0 -0
- {ledgix_python-0.1.1 → ledgix_python-0.1.2}/src/ledgix_python/__init__.py +0 -0
- {ledgix_python-0.1.1 → ledgix_python-0.1.2}/src/ledgix_python/adapters/__init__.py +0 -0
- {ledgix_python-0.1.1 → ledgix_python-0.1.2}/src/ledgix_python/adapters/crewai.py +0 -0
- {ledgix_python-0.1.1 → ledgix_python-0.1.2}/src/ledgix_python/adapters/langchain.py +0 -0
- {ledgix_python-0.1.1 → ledgix_python-0.1.2}/src/ledgix_python/adapters/llamaindex.py +0 -0
- {ledgix_python-0.1.1 → ledgix_python-0.1.2}/src/ledgix_python/exceptions.py +0 -0
- {ledgix_python-0.1.1 → ledgix_python-0.1.2}/src/ledgix_python/models.py +0 -0
- {ledgix_python-0.1.1 → ledgix_python-0.1.2}/tests/__init__.py +0 -0
- {ledgix_python-0.1.1 → ledgix_python-0.1.2}/tests/test_adapters.py +0 -0
- {ledgix_python-0.1.1 → ledgix_python-0.1.2}/tests/test_enforce.py +0 -0
- {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.
|
|
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,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.
|
|
90
|
-
|
|
91
|
-
|
|
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.
|
|
128
|
-
|
|
129
|
-
|
|
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.
|
|
161
|
-
|
|
162
|
-
|
|
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.
|
|
178
|
-
|
|
179
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
271
|
-
|
|
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
|
-
|
|
279
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|