asap-protocol 0.1.0__py3-none-any.whl
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.
- asap/__init__.py +7 -0
- asap/cli.py +220 -0
- asap/errors.py +150 -0
- asap/examples/README.md +25 -0
- asap/examples/__init__.py +1 -0
- asap/examples/coordinator.py +184 -0
- asap/examples/echo_agent.py +100 -0
- asap/examples/run_demo.py +120 -0
- asap/models/__init__.py +146 -0
- asap/models/base.py +55 -0
- asap/models/constants.py +14 -0
- asap/models/entities.py +410 -0
- asap/models/enums.py +71 -0
- asap/models/envelope.py +94 -0
- asap/models/ids.py +55 -0
- asap/models/parts.py +207 -0
- asap/models/payloads.py +423 -0
- asap/models/types.py +39 -0
- asap/observability/__init__.py +43 -0
- asap/observability/logging.py +216 -0
- asap/observability/metrics.py +399 -0
- asap/schemas.py +203 -0
- asap/state/__init__.py +22 -0
- asap/state/machine.py +86 -0
- asap/state/snapshot.py +265 -0
- asap/transport/__init__.py +84 -0
- asap/transport/client.py +399 -0
- asap/transport/handlers.py +444 -0
- asap/transport/jsonrpc.py +190 -0
- asap/transport/middleware.py +359 -0
- asap/transport/server.py +739 -0
- asap_protocol-0.1.0.dist-info/METADATA +251 -0
- asap_protocol-0.1.0.dist-info/RECORD +36 -0
- asap_protocol-0.1.0.dist-info/WHEEL +4 -0
- asap_protocol-0.1.0.dist-info/entry_points.txt +2 -0
- asap_protocol-0.1.0.dist-info/licenses/LICENSE +190 -0
asap/transport/client.py
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"""Async HTTP client for ASAP protocol communication.
|
|
2
|
+
|
|
3
|
+
This module provides an async HTTP client for sending ASAP messages
|
|
4
|
+
between agents using JSON-RPC 2.0 over HTTP.
|
|
5
|
+
|
|
6
|
+
The ASAPClient provides:
|
|
7
|
+
- Async context manager for connection lifecycle
|
|
8
|
+
- send() method for envelope exchange
|
|
9
|
+
- Automatic JSON-RPC wrapping
|
|
10
|
+
- Retry logic with idempotency keys
|
|
11
|
+
- Proper error handling and timeouts
|
|
12
|
+
- Structured logging for observability
|
|
13
|
+
|
|
14
|
+
Example:
|
|
15
|
+
>>> from asap.transport.client import ASAPClient
|
|
16
|
+
>>> from asap.models.envelope import Envelope
|
|
17
|
+
>>>
|
|
18
|
+
>>> async with ASAPClient("http://agent.example.com") as client:
|
|
19
|
+
... response = await client.send(request_envelope)
|
|
20
|
+
... print(response.payload_type)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import time
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
import httpx
|
|
27
|
+
|
|
28
|
+
from asap.models.envelope import Envelope
|
|
29
|
+
from asap.models.ids import generate_id
|
|
30
|
+
from asap.observability import get_logger
|
|
31
|
+
from asap.transport.jsonrpc import ASAP_METHOD
|
|
32
|
+
|
|
33
|
+
# Module logger
|
|
34
|
+
logger = get_logger(__name__)
|
|
35
|
+
|
|
36
|
+
# Default timeout in seconds
|
|
37
|
+
DEFAULT_TIMEOUT = 60.0
|
|
38
|
+
|
|
39
|
+
# Default maximum retries
|
|
40
|
+
DEFAULT_MAX_RETRIES = 3
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ASAPConnectionError(Exception):
|
|
44
|
+
"""Raised when connection to remote agent fails.
|
|
45
|
+
|
|
46
|
+
This error occurs when the HTTP connection cannot be established
|
|
47
|
+
or when the remote server returns an HTTP error status.
|
|
48
|
+
|
|
49
|
+
Attributes:
|
|
50
|
+
message: Error description
|
|
51
|
+
cause: Original exception that caused this error
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, message: str, cause: Exception | None = None) -> None:
|
|
55
|
+
"""Initialize connection error.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
message: Error description
|
|
59
|
+
cause: Original exception that caused this error
|
|
60
|
+
"""
|
|
61
|
+
super().__init__(message)
|
|
62
|
+
self.message = message
|
|
63
|
+
self.cause = cause
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ASAPTimeoutError(Exception):
|
|
67
|
+
"""Raised when request to remote agent times out.
|
|
68
|
+
|
|
69
|
+
This error occurs when the HTTP request exceeds the configured
|
|
70
|
+
timeout duration.
|
|
71
|
+
|
|
72
|
+
Attributes:
|
|
73
|
+
message: Error description
|
|
74
|
+
timeout: Timeout value in seconds
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(self, message: str, timeout: float | None = None) -> None:
|
|
78
|
+
"""Initialize timeout error.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
message: Error description
|
|
82
|
+
timeout: Timeout value in seconds
|
|
83
|
+
"""
|
|
84
|
+
super().__init__(message)
|
|
85
|
+
self.message = message
|
|
86
|
+
self.timeout = timeout
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class ASAPRemoteError(Exception):
|
|
90
|
+
"""Raised when remote agent returns an error response.
|
|
91
|
+
|
|
92
|
+
This error occurs when the JSON-RPC response contains an error
|
|
93
|
+
object, indicating the remote agent could not process the request.
|
|
94
|
+
|
|
95
|
+
Attributes:
|
|
96
|
+
code: JSON-RPC error code
|
|
97
|
+
message: Error message from remote
|
|
98
|
+
data: Optional additional error data
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def __init__(self, code: int, message: str, data: dict[str, Any] | None = None) -> None:
|
|
102
|
+
"""Initialize remote error.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
code: JSON-RPC error code
|
|
106
|
+
message: Error message from remote
|
|
107
|
+
data: Optional additional error data
|
|
108
|
+
"""
|
|
109
|
+
super().__init__(f"Remote error {code}: {message}")
|
|
110
|
+
self.code = code
|
|
111
|
+
self.message = message
|
|
112
|
+
self.data = data or {}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class ASAPClient:
|
|
116
|
+
"""Async HTTP client for ASAP protocol communication.
|
|
117
|
+
|
|
118
|
+
ASAPClient manages HTTP connections to remote ASAP agents and provides
|
|
119
|
+
methods for sending envelopes and receiving responses.
|
|
120
|
+
|
|
121
|
+
The client should be used as an async context manager to ensure
|
|
122
|
+
proper connection lifecycle management.
|
|
123
|
+
|
|
124
|
+
Attributes:
|
|
125
|
+
base_url: Base URL of the remote agent
|
|
126
|
+
timeout: Request timeout in seconds
|
|
127
|
+
max_retries: Maximum retry attempts for transient failures
|
|
128
|
+
is_connected: Whether the client has an active connection
|
|
129
|
+
|
|
130
|
+
Example:
|
|
131
|
+
>>> async with ASAPClient("http://localhost:8000") as client:
|
|
132
|
+
... response = await client.send(envelope)
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
def __init__(
|
|
136
|
+
self,
|
|
137
|
+
base_url: str,
|
|
138
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
139
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
140
|
+
transport: httpx.AsyncBaseTransport | httpx.BaseTransport | None = None,
|
|
141
|
+
) -> None:
|
|
142
|
+
"""Initialize ASAP client.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
base_url: Base URL of the remote agent (e.g., "http://localhost:8000")
|
|
146
|
+
timeout: Request timeout in seconds (default: 60)
|
|
147
|
+
max_retries: Maximum retry attempts for transient failures (default: 3)
|
|
148
|
+
transport: Optional custom transport (for testing). Can be sync or async.
|
|
149
|
+
"""
|
|
150
|
+
# Validate URL format and scheme
|
|
151
|
+
from urllib.parse import urlparse
|
|
152
|
+
|
|
153
|
+
parsed = urlparse(base_url)
|
|
154
|
+
if not parsed.scheme or not parsed.netloc:
|
|
155
|
+
raise ValueError(
|
|
156
|
+
f"Invalid base_url format: {base_url}. Must be a valid URL (e.g., http://localhost:8000)"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# Restrict to HTTP/HTTPS schemes only
|
|
160
|
+
if parsed.scheme.lower() not in ("http", "https"):
|
|
161
|
+
raise ValueError(
|
|
162
|
+
f"Invalid URL scheme: {parsed.scheme}. Only 'http' and 'https' are allowed. "
|
|
163
|
+
f"Received: {base_url}"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
self.base_url = base_url.rstrip("/")
|
|
167
|
+
self.timeout = timeout
|
|
168
|
+
self.max_retries = max_retries
|
|
169
|
+
self._transport = transport
|
|
170
|
+
self._client: httpx.AsyncClient | None = None
|
|
171
|
+
self._request_counter = 0
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def is_connected(self) -> bool:
|
|
175
|
+
"""Check if client has an active connection."""
|
|
176
|
+
return self._client is not None
|
|
177
|
+
|
|
178
|
+
async def __aenter__(self) -> "ASAPClient":
|
|
179
|
+
"""Enter async context and open connection."""
|
|
180
|
+
# Create the async client
|
|
181
|
+
if self._transport:
|
|
182
|
+
# MockTransport works for both sync and async, so we cast it
|
|
183
|
+
# This is safe because httpx.MockTransport is compatible with async usage
|
|
184
|
+
self._client = httpx.AsyncClient(
|
|
185
|
+
transport=self._transport, # type: ignore[arg-type]
|
|
186
|
+
timeout=self.timeout,
|
|
187
|
+
)
|
|
188
|
+
else:
|
|
189
|
+
self._client = httpx.AsyncClient(
|
|
190
|
+
timeout=self.timeout,
|
|
191
|
+
)
|
|
192
|
+
return self
|
|
193
|
+
|
|
194
|
+
async def __aexit__(
|
|
195
|
+
self,
|
|
196
|
+
exc_type: type[BaseException] | None,
|
|
197
|
+
exc_val: BaseException | None,
|
|
198
|
+
exc_tb: object,
|
|
199
|
+
) -> None:
|
|
200
|
+
"""Exit async context and close connection."""
|
|
201
|
+
if self._client:
|
|
202
|
+
await self._client.aclose()
|
|
203
|
+
self._client = None
|
|
204
|
+
|
|
205
|
+
async def send(self, envelope: Envelope) -> Envelope:
|
|
206
|
+
"""Send an envelope to the remote agent and receive response.
|
|
207
|
+
|
|
208
|
+
Wraps the envelope in a JSON-RPC 2.0 request, sends it to the
|
|
209
|
+
remote agent's /asap endpoint, and unwraps the response.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
envelope: ASAP envelope to send
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
Response envelope from the remote agent
|
|
216
|
+
|
|
217
|
+
Raises:
|
|
218
|
+
ASAPConnectionError: If connection fails or HTTP error occurs
|
|
219
|
+
ASAPTimeoutError: If request times out
|
|
220
|
+
ASAPRemoteError: If remote agent returns JSON-RPC error
|
|
221
|
+
|
|
222
|
+
Example:
|
|
223
|
+
>>> async with ASAPClient("http://localhost:8000") as client:
|
|
224
|
+
... response = await client.send(envelope)
|
|
225
|
+
... response.payload_type
|
|
226
|
+
"""
|
|
227
|
+
if not self._client:
|
|
228
|
+
raise ASAPConnectionError("Client not connected. Use 'async with' context.")
|
|
229
|
+
|
|
230
|
+
start_time = time.perf_counter()
|
|
231
|
+
|
|
232
|
+
# Generate idempotency key for retries
|
|
233
|
+
idempotency_key = generate_id()
|
|
234
|
+
|
|
235
|
+
# Increment request counter for JSON-RPC id
|
|
236
|
+
self._request_counter += 1
|
|
237
|
+
request_id = f"req-{self._request_counter}"
|
|
238
|
+
|
|
239
|
+
# Log send attempt
|
|
240
|
+
logger.info(
|
|
241
|
+
"asap.client.send",
|
|
242
|
+
target_url=self.base_url,
|
|
243
|
+
envelope_id=envelope.id,
|
|
244
|
+
trace_id=envelope.trace_id,
|
|
245
|
+
payload_type=envelope.payload_type,
|
|
246
|
+
idempotency_key=idempotency_key,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
# Build JSON-RPC request
|
|
250
|
+
json_rpc_request = {
|
|
251
|
+
"jsonrpc": "2.0",
|
|
252
|
+
"method": ASAP_METHOD,
|
|
253
|
+
"params": {
|
|
254
|
+
"envelope": envelope.model_dump(mode="json"),
|
|
255
|
+
"idempotency_key": idempotency_key,
|
|
256
|
+
},
|
|
257
|
+
"id": request_id,
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
# Attempt with retries
|
|
261
|
+
last_exception: Exception | None = None
|
|
262
|
+
for attempt in range(self.max_retries):
|
|
263
|
+
try:
|
|
264
|
+
response = await self._client.post(
|
|
265
|
+
f"{self.base_url}/asap",
|
|
266
|
+
json=json_rpc_request,
|
|
267
|
+
headers={
|
|
268
|
+
"Content-Type": "application/json",
|
|
269
|
+
"X-Idempotency-Key": idempotency_key,
|
|
270
|
+
},
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
# Check HTTP status
|
|
274
|
+
if response.status_code >= 500:
|
|
275
|
+
# Server errors (5xx) are retriable
|
|
276
|
+
if attempt < self.max_retries - 1:
|
|
277
|
+
logger.warning(
|
|
278
|
+
"asap.client.server_error",
|
|
279
|
+
status_code=response.status_code,
|
|
280
|
+
attempt=attempt + 1,
|
|
281
|
+
max_retries=self.max_retries,
|
|
282
|
+
)
|
|
283
|
+
last_exception = ASAPConnectionError(
|
|
284
|
+
f"HTTP server error {response.status_code}: {response.text}"
|
|
285
|
+
)
|
|
286
|
+
continue
|
|
287
|
+
raise ASAPConnectionError(
|
|
288
|
+
f"HTTP server error {response.status_code}: {response.text}"
|
|
289
|
+
)
|
|
290
|
+
if response.status_code >= 400:
|
|
291
|
+
# Client errors (4xx) are not retriable
|
|
292
|
+
raise ASAPConnectionError(
|
|
293
|
+
f"HTTP client error {response.status_code}: {response.text}"
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
# Parse JSON response
|
|
297
|
+
try:
|
|
298
|
+
json_response = response.json()
|
|
299
|
+
except Exception as e:
|
|
300
|
+
raise ASAPRemoteError(-32700, f"Invalid JSON response: {e}") from e
|
|
301
|
+
|
|
302
|
+
# Check for JSON-RPC error
|
|
303
|
+
if "error" in json_response:
|
|
304
|
+
error = json_response["error"]
|
|
305
|
+
raise ASAPRemoteError(
|
|
306
|
+
error.get("code", -32603),
|
|
307
|
+
error.get("message", "Unknown error"),
|
|
308
|
+
error.get("data"),
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
# Extract envelope from result
|
|
312
|
+
result = json_response.get("result", {})
|
|
313
|
+
envelope_data = result.get("envelope")
|
|
314
|
+
if not envelope_data:
|
|
315
|
+
raise ASAPRemoteError(-32603, "Missing envelope in response")
|
|
316
|
+
|
|
317
|
+
response_envelope = Envelope(**envelope_data)
|
|
318
|
+
|
|
319
|
+
# Calculate duration and log success
|
|
320
|
+
duration_ms = (time.perf_counter() - start_time) * 1000
|
|
321
|
+
logger.info(
|
|
322
|
+
"asap.client.response",
|
|
323
|
+
target_url=self.base_url,
|
|
324
|
+
envelope_id=envelope.id,
|
|
325
|
+
response_id=response_envelope.id,
|
|
326
|
+
trace_id=envelope.trace_id,
|
|
327
|
+
duration_ms=round(duration_ms, 2),
|
|
328
|
+
attempts=attempt + 1,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
return response_envelope
|
|
332
|
+
|
|
333
|
+
except httpx.ConnectError as e:
|
|
334
|
+
last_exception = ASAPConnectionError(f"Connection error: {e}", cause=e)
|
|
335
|
+
# Log retry attempt
|
|
336
|
+
if attempt < self.max_retries - 1:
|
|
337
|
+
logger.warning(
|
|
338
|
+
"asap.client.retry",
|
|
339
|
+
target_url=self.base_url,
|
|
340
|
+
envelope_id=envelope.id,
|
|
341
|
+
attempt=attempt + 1,
|
|
342
|
+
max_retries=self.max_retries,
|
|
343
|
+
error=str(e),
|
|
344
|
+
)
|
|
345
|
+
continue
|
|
346
|
+
# Log final failure
|
|
347
|
+
duration_ms = (time.perf_counter() - start_time) * 1000
|
|
348
|
+
logger.error(
|
|
349
|
+
"asap.client.error",
|
|
350
|
+
target_url=self.base_url,
|
|
351
|
+
envelope_id=envelope.id,
|
|
352
|
+
error="Connection failed after retries",
|
|
353
|
+
error_type="ASAPConnectionError",
|
|
354
|
+
duration_ms=round(duration_ms, 2),
|
|
355
|
+
attempts=attempt + 1,
|
|
356
|
+
)
|
|
357
|
+
raise last_exception from e
|
|
358
|
+
|
|
359
|
+
except httpx.TimeoutException as e:
|
|
360
|
+
duration_ms = (time.perf_counter() - start_time) * 1000
|
|
361
|
+
last_exception = ASAPTimeoutError(
|
|
362
|
+
f"Request timeout after {self.timeout}s", timeout=self.timeout
|
|
363
|
+
)
|
|
364
|
+
# Log timeout (don't retry)
|
|
365
|
+
logger.error(
|
|
366
|
+
"asap.client.error",
|
|
367
|
+
target_url=self.base_url,
|
|
368
|
+
envelope_id=envelope.id,
|
|
369
|
+
error="Request timeout",
|
|
370
|
+
error_type="ASAPTimeoutError",
|
|
371
|
+
timeout=self.timeout,
|
|
372
|
+
duration_ms=round(duration_ms, 2),
|
|
373
|
+
)
|
|
374
|
+
raise last_exception from e
|
|
375
|
+
|
|
376
|
+
except (ASAPConnectionError, ASAPRemoteError, ASAPTimeoutError):
|
|
377
|
+
# Re-raise our custom errors
|
|
378
|
+
raise
|
|
379
|
+
|
|
380
|
+
except Exception as e:
|
|
381
|
+
# Log unexpected error
|
|
382
|
+
duration_ms = (time.perf_counter() - start_time) * 1000
|
|
383
|
+
logger.exception(
|
|
384
|
+
"asap.client.error",
|
|
385
|
+
target_url=self.base_url,
|
|
386
|
+
envelope_id=envelope.id,
|
|
387
|
+
error=str(e),
|
|
388
|
+
error_type=type(e).__name__,
|
|
389
|
+
duration_ms=round(duration_ms, 2),
|
|
390
|
+
)
|
|
391
|
+
# Wrap unexpected errors
|
|
392
|
+
raise ASAPConnectionError(f"Unexpected error: {e}", cause=e) from e
|
|
393
|
+
|
|
394
|
+
# Defensive code: This should never be reached because the loop above
|
|
395
|
+
# always either returns successfully or raises an exception.
|
|
396
|
+
# Kept as a safety net for future code changes.
|
|
397
|
+
if last_exception: # pragma: no cover
|
|
398
|
+
raise last_exception
|
|
399
|
+
raise ASAPConnectionError("Max retries exceeded") # pragma: no cover
|