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.
@@ -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