asap-protocol 0.3.0__py3-none-any.whl → 1.0.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 +1 -1
- asap/cli.py +137 -2
- asap/errors.py +167 -0
- asap/examples/README.md +81 -10
- asap/examples/auth_patterns.py +212 -0
- asap/examples/error_recovery.py +248 -0
- asap/examples/long_running.py +287 -0
- asap/examples/mcp_integration.py +240 -0
- asap/examples/multi_step_workflow.py +134 -0
- asap/examples/orchestration.py +293 -0
- asap/examples/rate_limiting.py +137 -0
- asap/examples/run_demo.py +9 -4
- asap/examples/secure_handler.py +84 -0
- asap/examples/state_migration.py +240 -0
- asap/examples/streaming_response.py +108 -0
- asap/examples/websocket_concept.py +129 -0
- asap/mcp/__init__.py +43 -0
- asap/mcp/client.py +224 -0
- asap/mcp/protocol.py +179 -0
- asap/mcp/server.py +333 -0
- asap/mcp/server_runner.py +40 -0
- asap/models/__init__.py +4 -0
- asap/models/base.py +0 -3
- asap/models/constants.py +76 -1
- asap/models/entities.py +58 -7
- asap/models/envelope.py +14 -1
- asap/models/ids.py +8 -4
- asap/models/parts.py +33 -3
- asap/models/validators.py +16 -0
- asap/observability/__init__.py +6 -0
- asap/observability/dashboards/README.md +24 -0
- asap/observability/dashboards/asap-detailed.json +131 -0
- asap/observability/dashboards/asap-red.json +129 -0
- asap/observability/logging.py +81 -1
- asap/observability/metrics.py +15 -1
- asap/observability/trace_parser.py +238 -0
- asap/observability/trace_ui.py +218 -0
- asap/observability/tracing.py +293 -0
- asap/state/machine.py +15 -2
- asap/state/snapshot.py +0 -9
- asap/testing/__init__.py +31 -0
- asap/testing/assertions.py +108 -0
- asap/testing/fixtures.py +113 -0
- asap/testing/mocks.py +152 -0
- asap/transport/__init__.py +31 -0
- asap/transport/cache.py +180 -0
- asap/transport/circuit_breaker.py +194 -0
- asap/transport/client.py +989 -72
- asap/transport/compression.py +389 -0
- asap/transport/handlers.py +106 -53
- asap/transport/middleware.py +64 -39
- asap/transport/server.py +461 -94
- asap/transport/validators.py +320 -0
- asap/utils/__init__.py +7 -0
- asap/utils/sanitization.py +134 -0
- asap_protocol-1.0.0.dist-info/METADATA +264 -0
- asap_protocol-1.0.0.dist-info/RECORD +70 -0
- asap_protocol-0.3.0.dist-info/METADATA +0 -227
- asap_protocol-0.3.0.dist-info/RECORD +0 -37
- {asap_protocol-0.3.0.dist-info → asap_protocol-1.0.0.dist-info}/WHEEL +0 -0
- {asap_protocol-0.3.0.dist-info → asap_protocol-1.0.0.dist-info}/entry_points.txt +0 -0
- {asap_protocol-0.3.0.dist-info → asap_protocol-1.0.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""Compression support for ASAP protocol transport layer.
|
|
2
|
+
|
|
3
|
+
This module provides compression and decompression utilities for reducing
|
|
4
|
+
bandwidth usage in agent-to-agent communication.
|
|
5
|
+
|
|
6
|
+
Supported compression algorithms:
|
|
7
|
+
- gzip: Standard compression, widely supported (default)
|
|
8
|
+
- brotli: Better compression ratio (optional, requires brotli package)
|
|
9
|
+
|
|
10
|
+
Compression is applied automatically when:
|
|
11
|
+
- Request body exceeds COMPRESSION_THRESHOLD (default: 1KB)
|
|
12
|
+
- Server indicates support via Accept-Encoding header
|
|
13
|
+
|
|
14
|
+
Example:
|
|
15
|
+
>>> from asap.transport.compression import compress_payload, decompress_payload
|
|
16
|
+
>>>
|
|
17
|
+
>>> # Compress large payload
|
|
18
|
+
>>> data = b'{"large": "payload..."}' * 100
|
|
19
|
+
>>> compressed, encoding = compress_payload(data)
|
|
20
|
+
>>> # encoding = "gzip" or "br" depending on configuration
|
|
21
|
+
>>>
|
|
22
|
+
>>> # Decompress received payload
|
|
23
|
+
>>> original = decompress_payload(compressed, encoding)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import gzip
|
|
27
|
+
from enum import Enum
|
|
28
|
+
|
|
29
|
+
from asap.observability import get_logger
|
|
30
|
+
|
|
31
|
+
# Module logger
|
|
32
|
+
logger = get_logger(__name__)
|
|
33
|
+
|
|
34
|
+
# Compression threshold: only compress payloads larger than this size (bytes)
|
|
35
|
+
COMPRESSION_THRESHOLD = 1024 # 1KB
|
|
36
|
+
|
|
37
|
+
# Gzip compression level (1-9, higher = better compression but slower)
|
|
38
|
+
GZIP_COMPRESSION_LEVEL = 6
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class CompressionAlgorithm(str, Enum):
|
|
42
|
+
"""Supported compression algorithms."""
|
|
43
|
+
|
|
44
|
+
GZIP = "gzip"
|
|
45
|
+
BROTLI = "br"
|
|
46
|
+
IDENTITY = "identity" # No compression
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_brotli_available() -> bool:
|
|
50
|
+
"""Check if brotli compression is available.
|
|
51
|
+
|
|
52
|
+
Brotli is an optional dependency that provides better compression
|
|
53
|
+
ratios than gzip, especially for JSON payloads.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
True if brotli package is installed, False otherwise
|
|
57
|
+
"""
|
|
58
|
+
try:
|
|
59
|
+
import brotli # noqa: F401
|
|
60
|
+
|
|
61
|
+
return True
|
|
62
|
+
except ImportError:
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_supported_encodings() -> list[str]:
|
|
67
|
+
"""Get list of supported Content-Encoding values.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
List of supported encoding names (e.g., ["gzip", "br"])
|
|
71
|
+
"""
|
|
72
|
+
encodings = ["gzip"]
|
|
73
|
+
if is_brotli_available():
|
|
74
|
+
encodings.insert(0, "br") # Prefer brotli when available
|
|
75
|
+
return encodings
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def get_accept_encoding_header() -> str:
|
|
79
|
+
"""Get Accept-Encoding header value for client requests.
|
|
80
|
+
|
|
81
|
+
Returns a comma-separated list of supported encodings with quality
|
|
82
|
+
values to indicate preference.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Accept-Encoding header value (e.g., "br, gzip, identity")
|
|
86
|
+
"""
|
|
87
|
+
encodings = get_supported_encodings()
|
|
88
|
+
# Add identity as fallback (always supported)
|
|
89
|
+
encodings.append("identity")
|
|
90
|
+
return ", ".join(encodings)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def compress_gzip(data: bytes) -> bytes:
|
|
94
|
+
"""Compress data using gzip algorithm.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
data: Raw bytes to compress
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
Gzip compressed bytes
|
|
101
|
+
"""
|
|
102
|
+
return gzip.compress(data, compresslevel=GZIP_COMPRESSION_LEVEL)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def decompress_gzip(data: bytes) -> bytes:
|
|
106
|
+
"""Decompress gzip data.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
data: Gzip compressed bytes
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Decompressed bytes
|
|
113
|
+
|
|
114
|
+
Raises:
|
|
115
|
+
OSError: If data is not valid gzip format
|
|
116
|
+
"""
|
|
117
|
+
return gzip.decompress(data)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def compress_brotli(data: bytes) -> bytes:
|
|
121
|
+
"""Compress data using brotli algorithm.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
data: Raw bytes to compress
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
Brotli compressed bytes
|
|
128
|
+
|
|
129
|
+
Raises:
|
|
130
|
+
ImportError: If brotli package is not installed
|
|
131
|
+
"""
|
|
132
|
+
import brotli
|
|
133
|
+
|
|
134
|
+
# Quality 4 is a good balance of speed and compression ratio
|
|
135
|
+
result: bytes = brotli.compress(data, quality=4)
|
|
136
|
+
return result
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def decompress_brotli(data: bytes) -> bytes:
|
|
140
|
+
"""Decompress brotli data.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
data: Brotli compressed bytes
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Decompressed bytes
|
|
147
|
+
|
|
148
|
+
Raises:
|
|
149
|
+
ImportError: If brotli package is not installed
|
|
150
|
+
brotli.error: If data is not valid brotli format
|
|
151
|
+
"""
|
|
152
|
+
import brotli
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
result: bytes = brotli.decompress(data)
|
|
156
|
+
return result
|
|
157
|
+
except brotli.error as e:
|
|
158
|
+
raise OSError(f"Brotli decompression failed: {e}") from e
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def compress_payload(
|
|
162
|
+
data: bytes,
|
|
163
|
+
preferred_algorithm: CompressionAlgorithm | None = None,
|
|
164
|
+
threshold: int = COMPRESSION_THRESHOLD,
|
|
165
|
+
) -> tuple[bytes, CompressionAlgorithm]:
|
|
166
|
+
"""Compress payload if it exceeds threshold.
|
|
167
|
+
|
|
168
|
+
Compresses the payload using the preferred algorithm if specified,
|
|
169
|
+
otherwise uses the best available algorithm (brotli > gzip).
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
data: Raw bytes to compress
|
|
173
|
+
preferred_algorithm: Optional preferred compression algorithm.
|
|
174
|
+
If None, uses brotli if available, otherwise gzip.
|
|
175
|
+
threshold: Minimum payload size to trigger compression (default: 1KB).
|
|
176
|
+
Payloads smaller than this are returned as-is with IDENTITY encoding.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
Tuple of (compressed_data, algorithm_used)
|
|
180
|
+
If compression is skipped, returns (original_data, IDENTITY)
|
|
181
|
+
|
|
182
|
+
>>> compressed, algorithm = compress_payload(data)
|
|
183
|
+
>>> print(f"Compressed {len(data)} -> {len(compressed)} bytes using {algorithm.value}")
|
|
184
|
+
|
|
185
|
+
Note:
|
|
186
|
+
Compression adds CPU overhead which may increase latency for very small payloads.
|
|
187
|
+
For extremely latency-sensitive scenarios, consider increasing the threshold
|
|
188
|
+
or disabling compression if payloads are typically small.
|
|
189
|
+
"""
|
|
190
|
+
# Skip compression for small payloads
|
|
191
|
+
if len(data) < threshold:
|
|
192
|
+
logger.debug(
|
|
193
|
+
"asap.compression.skipped",
|
|
194
|
+
size=len(data),
|
|
195
|
+
threshold=threshold,
|
|
196
|
+
reason="payload_below_threshold",
|
|
197
|
+
)
|
|
198
|
+
return data, CompressionAlgorithm.IDENTITY
|
|
199
|
+
|
|
200
|
+
# Determine algorithm to use
|
|
201
|
+
if preferred_algorithm is not None:
|
|
202
|
+
algorithm = preferred_algorithm
|
|
203
|
+
elif is_brotli_available():
|
|
204
|
+
# TODO: Add prefer_fast_compression option to prefer gzip even when brotli is available
|
|
205
|
+
algorithm = CompressionAlgorithm.BROTLI
|
|
206
|
+
else:
|
|
207
|
+
algorithm = CompressionAlgorithm.GZIP
|
|
208
|
+
|
|
209
|
+
# Skip if identity is requested
|
|
210
|
+
if algorithm == CompressionAlgorithm.IDENTITY:
|
|
211
|
+
return data, CompressionAlgorithm.IDENTITY
|
|
212
|
+
|
|
213
|
+
# Compress using selected algorithm
|
|
214
|
+
original_size = len(data)
|
|
215
|
+
try:
|
|
216
|
+
if algorithm == CompressionAlgorithm.BROTLI:
|
|
217
|
+
compressed = compress_brotli(data)
|
|
218
|
+
else:
|
|
219
|
+
compressed = compress_gzip(data)
|
|
220
|
+
|
|
221
|
+
compressed_size = len(compressed)
|
|
222
|
+
reduction_pct = (1 - compressed_size / original_size) * 100
|
|
223
|
+
|
|
224
|
+
logger.debug(
|
|
225
|
+
"asap.compression.applied",
|
|
226
|
+
algorithm=algorithm.value,
|
|
227
|
+
original_size=original_size,
|
|
228
|
+
compressed_size=compressed_size,
|
|
229
|
+
reduction_percent=round(reduction_pct, 1),
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# Only use compression if it actually reduces size
|
|
233
|
+
if compressed_size >= original_size:
|
|
234
|
+
logger.debug(
|
|
235
|
+
"asap.compression.ineffective",
|
|
236
|
+
algorithm=algorithm.value,
|
|
237
|
+
original_size=original_size,
|
|
238
|
+
compressed_size=compressed_size,
|
|
239
|
+
reason="compression_increased_size",
|
|
240
|
+
)
|
|
241
|
+
return data, CompressionAlgorithm.IDENTITY
|
|
242
|
+
|
|
243
|
+
return compressed, algorithm
|
|
244
|
+
|
|
245
|
+
except ImportError:
|
|
246
|
+
# Brotli not available, fallback to gzip
|
|
247
|
+
logger.warning(
|
|
248
|
+
"asap.compression.fallback",
|
|
249
|
+
requested=algorithm.value,
|
|
250
|
+
fallback="gzip",
|
|
251
|
+
reason="brotli_not_installed",
|
|
252
|
+
)
|
|
253
|
+
return compress_payload(data, CompressionAlgorithm.GZIP, threshold)
|
|
254
|
+
except Exception as e:
|
|
255
|
+
# Compression failed, return original
|
|
256
|
+
logger.warning(
|
|
257
|
+
"asap.compression.failed",
|
|
258
|
+
algorithm=algorithm.value,
|
|
259
|
+
error=str(e),
|
|
260
|
+
error_type=type(e).__name__,
|
|
261
|
+
)
|
|
262
|
+
return data, CompressionAlgorithm.IDENTITY
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def decompress_payload(
|
|
266
|
+
data: bytes,
|
|
267
|
+
encoding: str,
|
|
268
|
+
) -> bytes:
|
|
269
|
+
"""Decompress payload based on Content-Encoding header.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
data: Compressed bytes
|
|
273
|
+
encoding: Content-Encoding header value (e.g., "gzip", "br", "identity")
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
Decompressed bytes
|
|
277
|
+
|
|
278
|
+
Raises:
|
|
279
|
+
ValueError: If encoding is not supported
|
|
280
|
+
OSError: If decompression fails (invalid compressed data)
|
|
281
|
+
|
|
282
|
+
Example:
|
|
283
|
+
>>> compressed_data = gzip.compress(b'{"message": "hello"}')
|
|
284
|
+
>>> original = decompress_payload(compressed_data, "gzip")
|
|
285
|
+
"""
|
|
286
|
+
# Normalize encoding name
|
|
287
|
+
encoding_lower = encoding.lower().strip()
|
|
288
|
+
|
|
289
|
+
# Handle identity (no compression)
|
|
290
|
+
if encoding_lower in ("identity", ""):
|
|
291
|
+
return data
|
|
292
|
+
|
|
293
|
+
original_size = len(data)
|
|
294
|
+
|
|
295
|
+
try:
|
|
296
|
+
if encoding_lower == "gzip":
|
|
297
|
+
decompressed = decompress_gzip(data)
|
|
298
|
+
elif encoding_lower == "br":
|
|
299
|
+
if not is_brotli_available():
|
|
300
|
+
raise ValueError(
|
|
301
|
+
"Brotli decompression requested but brotli package is not installed. "
|
|
302
|
+
"Install with: pip install brotli"
|
|
303
|
+
)
|
|
304
|
+
decompressed = decompress_brotli(data)
|
|
305
|
+
else:
|
|
306
|
+
raise ValueError(
|
|
307
|
+
f"Unsupported Content-Encoding: {encoding}. Supported: gzip, br, identity"
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
decompressed_size = len(decompressed)
|
|
311
|
+
logger.debug(
|
|
312
|
+
"asap.decompression.applied",
|
|
313
|
+
encoding=encoding_lower,
|
|
314
|
+
compressed_size=original_size,
|
|
315
|
+
decompressed_size=decompressed_size,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
return decompressed
|
|
319
|
+
|
|
320
|
+
except ImportError as e:
|
|
321
|
+
raise ValueError(
|
|
322
|
+
f"Cannot decompress {encoding}: required package not installed. {e}"
|
|
323
|
+
) from e
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def select_best_encoding(accept_encoding: str | None) -> CompressionAlgorithm:
|
|
327
|
+
"""Select best compression algorithm based on Accept-Encoding header.
|
|
328
|
+
|
|
329
|
+
Parses the Accept-Encoding header and selects the best supported algorithm
|
|
330
|
+
based on client preferences and availability.
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
accept_encoding: Accept-Encoding header value (e.g., "gzip, br;q=0.9")
|
|
334
|
+
If None, returns IDENTITY (no compression).
|
|
335
|
+
|
|
336
|
+
Returns:
|
|
337
|
+
Best available compression algorithm
|
|
338
|
+
|
|
339
|
+
Example:
|
|
340
|
+
>>> algorithm = select_best_encoding("br, gzip;q=0.9, identity;q=0.5")
|
|
341
|
+
>>> print(algorithm.value) # "br" if brotli available, else "gzip"
|
|
342
|
+
"""
|
|
343
|
+
if not accept_encoding:
|
|
344
|
+
return CompressionAlgorithm.IDENTITY
|
|
345
|
+
|
|
346
|
+
# Parse Accept-Encoding header (simplified parser)
|
|
347
|
+
# Format: encoding[;q=weight], encoding[;q=weight], ...
|
|
348
|
+
encodings: dict[str, float] = {}
|
|
349
|
+
for part in accept_encoding.split(","):
|
|
350
|
+
part = part.strip()
|
|
351
|
+
if not part:
|
|
352
|
+
continue
|
|
353
|
+
|
|
354
|
+
# Split encoding and quality
|
|
355
|
+
if ";q=" in part.lower():
|
|
356
|
+
encoding, q_str = part.lower().split(";q=", 1)
|
|
357
|
+
try:
|
|
358
|
+
quality = float(q_str)
|
|
359
|
+
except ValueError:
|
|
360
|
+
quality = 1.0
|
|
361
|
+
else:
|
|
362
|
+
encoding = part.lower()
|
|
363
|
+
quality = 1.0
|
|
364
|
+
|
|
365
|
+
encoding = encoding.strip()
|
|
366
|
+
if encoding:
|
|
367
|
+
encodings[encoding] = quality
|
|
368
|
+
|
|
369
|
+
# Sort by quality (highest first) and filter to supported encodings
|
|
370
|
+
supported = get_supported_encodings()
|
|
371
|
+
candidates: list[tuple[str, float]] = []
|
|
372
|
+
|
|
373
|
+
for enc, quality in encodings.items():
|
|
374
|
+
if enc in supported or enc == "identity":
|
|
375
|
+
candidates.append((enc, quality))
|
|
376
|
+
|
|
377
|
+
# Sort by quality descending
|
|
378
|
+
candidates.sort(key=lambda x: x[1], reverse=True)
|
|
379
|
+
|
|
380
|
+
if not candidates:
|
|
381
|
+
return CompressionAlgorithm.IDENTITY
|
|
382
|
+
|
|
383
|
+
best_encoding = candidates[0][0]
|
|
384
|
+
|
|
385
|
+
if best_encoding == "br" and is_brotli_available():
|
|
386
|
+
return CompressionAlgorithm.BROTLI
|
|
387
|
+
if best_encoding == "gzip":
|
|
388
|
+
return CompressionAlgorithm.GZIP
|
|
389
|
+
return CompressionAlgorithm.IDENTITY
|
asap/transport/handlers.py
CHANGED
|
@@ -32,7 +32,7 @@ import time
|
|
|
32
32
|
from collections.abc import Awaitable
|
|
33
33
|
from concurrent.futures import Executor
|
|
34
34
|
from threading import RLock
|
|
35
|
-
from typing import Protocol, cast
|
|
35
|
+
from typing import Callable, Protocol, TypeAlias, cast
|
|
36
36
|
|
|
37
37
|
from asap.errors import ASAPError
|
|
38
38
|
from asap.models.entities import Manifest
|
|
@@ -40,7 +40,8 @@ from asap.models.enums import TaskStatus
|
|
|
40
40
|
from asap.models.envelope import Envelope
|
|
41
41
|
from asap.models.ids import generate_id
|
|
42
42
|
from asap.models.payloads import TaskRequest, TaskResponse
|
|
43
|
-
from asap.observability import get_logger
|
|
43
|
+
from asap.observability import get_logger, get_metrics
|
|
44
|
+
from asap.observability.tracing import handler_span_context
|
|
44
45
|
|
|
45
46
|
# Module logger
|
|
46
47
|
logger = get_logger(__name__)
|
|
@@ -94,6 +95,49 @@ Returns:
|
|
|
94
95
|
Response envelope to send back (sync) or awaitable (async)
|
|
95
96
|
"""
|
|
96
97
|
|
|
98
|
+
# Type alias for factories that return a sync handler (useful in tests)
|
|
99
|
+
SyncHandlerFactory: TypeAlias = Callable[[], SyncHandler]
|
|
100
|
+
"""Type alias for callables that return a SyncHandler (e.g. create_echo_handler)."""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def validate_handler(handler: Handler) -> None:
|
|
104
|
+
"""Validate that a handler has the required signature (envelope, manifest).
|
|
105
|
+
|
|
106
|
+
Checks that the handler is callable and accepts exactly two parameters
|
|
107
|
+
(envelope and manifest), matching the Handler protocol. Use when
|
|
108
|
+
registering custom handlers to fail fast on invalid signatures.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
handler: The handler callable to validate (sync or async).
|
|
112
|
+
|
|
113
|
+
Raises:
|
|
114
|
+
TypeError: If handler is not callable or does not have the required
|
|
115
|
+
signature (two parameters for a function, or three for a bound
|
|
116
|
+
callable with self/cls).
|
|
117
|
+
|
|
118
|
+
Example:
|
|
119
|
+
>>> validate_handler(create_echo_handler())
|
|
120
|
+
>>> def bad(x, y, z): ...
|
|
121
|
+
>>> validate_handler(bad)
|
|
122
|
+
Traceback (most recent call last):
|
|
123
|
+
...
|
|
124
|
+
TypeError: Handler must accept (envelope, manifest); got 3 parameters
|
|
125
|
+
"""
|
|
126
|
+
if not callable(handler):
|
|
127
|
+
raise TypeError("Handler must be callable")
|
|
128
|
+
try:
|
|
129
|
+
sig = inspect.signature(handler)
|
|
130
|
+
except (ValueError, TypeError):
|
|
131
|
+
raise TypeError("Handler signature could not be inspected") from None
|
|
132
|
+
params = list(sig.parameters)
|
|
133
|
+
# Allow (envelope, manifest) or (self, envelope, manifest) / (cls, envelope, manifest)
|
|
134
|
+
if len(params) == 2 or len(params) == 3 and params[0] in ("self", "cls"):
|
|
135
|
+
pass
|
|
136
|
+
else:
|
|
137
|
+
raise TypeError(
|
|
138
|
+
f"Handler must accept (envelope, manifest); got {len(params)} parameters: {params}"
|
|
139
|
+
)
|
|
140
|
+
|
|
97
141
|
|
|
98
142
|
class HandlerNotFoundError(ASAPError):
|
|
99
143
|
"""Raised when no handler is registered for a payload type.
|
|
@@ -181,6 +225,7 @@ class HandlerRegistry:
|
|
|
181
225
|
>>> registry = HandlerRegistry()
|
|
182
226
|
>>> registry.register("task.request", create_echo_handler())
|
|
183
227
|
"""
|
|
228
|
+
validate_handler(handler)
|
|
184
229
|
with self._lock:
|
|
185
230
|
is_override = payload_type in self._handlers
|
|
186
231
|
self._handlers[payload_type] = handler
|
|
@@ -246,7 +291,6 @@ class HandlerRegistry:
|
|
|
246
291
|
raise HandlerNotFoundError(payload_type)
|
|
247
292
|
handler = self._handlers[payload_type]
|
|
248
293
|
|
|
249
|
-
# Log dispatch start
|
|
250
294
|
logger.debug(
|
|
251
295
|
"asap.handler.dispatch",
|
|
252
296
|
payload_type=payload_type,
|
|
@@ -254,13 +298,8 @@ class HandlerRegistry:
|
|
|
254
298
|
handler_name=handler.__name__ if hasattr(handler, "__name__") else str(handler),
|
|
255
299
|
)
|
|
256
300
|
|
|
257
|
-
# Execute handler outside the lock to allow concurrent dispatches
|
|
258
301
|
try:
|
|
259
|
-
# Note: dispatch() only works with sync handlers that return Envelope directly
|
|
260
|
-
# For async handlers, use dispatch_async() instead
|
|
261
|
-
# Type narrowing: we expect sync handlers here
|
|
262
302
|
result = handler(envelope, manifest)
|
|
263
|
-
# For sync handlers, result is Envelope directly
|
|
264
303
|
if inspect.isawaitable(result):
|
|
265
304
|
raise TypeError(
|
|
266
305
|
f"Handler {handler} returned awaitable in sync dispatch(). "
|
|
@@ -323,7 +362,6 @@ class HandlerRegistry:
|
|
|
323
362
|
raise HandlerNotFoundError(payload_type)
|
|
324
363
|
handler = self._handlers[payload_type]
|
|
325
364
|
|
|
326
|
-
# Log dispatch start
|
|
327
365
|
logger.debug(
|
|
328
366
|
"asap.handler.dispatch",
|
|
329
367
|
payload_type=payload_type,
|
|
@@ -331,48 +369,62 @@ class HandlerRegistry:
|
|
|
331
369
|
handler_name=handler.__name__ if hasattr(handler, "__name__") else str(handler),
|
|
332
370
|
)
|
|
333
371
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
loop = asyncio.get_event_loop()
|
|
345
|
-
# Use bounded executor if provided, otherwise use default (unbounded)
|
|
346
|
-
executor = self._executor if self._executor is not None else None
|
|
347
|
-
result: object = await loop.run_in_executor(executor, handler, envelope, manifest)
|
|
348
|
-
# Check if result is awaitable (handles async __call__ methods)
|
|
349
|
-
if inspect.isawaitable(result):
|
|
350
|
-
response = await result
|
|
372
|
+
agent_urn = manifest.id
|
|
373
|
+
with handler_span_context(
|
|
374
|
+
payload_type=payload_type,
|
|
375
|
+
agent_urn=agent_urn,
|
|
376
|
+
envelope_id=envelope.id,
|
|
377
|
+
):
|
|
378
|
+
try:
|
|
379
|
+
response: Envelope
|
|
380
|
+
if inspect.iscoroutinefunction(handler):
|
|
381
|
+
response = await handler(envelope, manifest)
|
|
351
382
|
else:
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
duration_ms=
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
383
|
+
loop = asyncio.get_running_loop()
|
|
384
|
+
executor = self._executor if self._executor is not None else None
|
|
385
|
+
result: object = await loop.run_in_executor(
|
|
386
|
+
executor, handler, envelope, manifest
|
|
387
|
+
)
|
|
388
|
+
if inspect.isawaitable(result):
|
|
389
|
+
response = await result
|
|
390
|
+
else:
|
|
391
|
+
response = cast(Envelope, result)
|
|
392
|
+
|
|
393
|
+
duration_ms = (time.perf_counter() - start_time) * 1000
|
|
394
|
+
duration_seconds = duration_ms / 1000.0
|
|
395
|
+
logger.debug(
|
|
396
|
+
"asap.handler.completed",
|
|
397
|
+
payload_type=payload_type,
|
|
398
|
+
envelope_id=envelope.id,
|
|
399
|
+
response_id=response.id,
|
|
400
|
+
duration_ms=round(duration_ms, 2),
|
|
401
|
+
)
|
|
402
|
+
metrics = get_metrics()
|
|
403
|
+
metrics.increment_counter(
|
|
404
|
+
"asap_handler_executions_total",
|
|
405
|
+
{"payload_type": payload_type},
|
|
406
|
+
)
|
|
407
|
+
metrics.observe_histogram(
|
|
408
|
+
"asap_handler_duration_seconds",
|
|
409
|
+
duration_seconds,
|
|
410
|
+
{"payload_type": payload_type},
|
|
411
|
+
)
|
|
412
|
+
return response
|
|
413
|
+
except Exception as e:
|
|
414
|
+
duration_ms = (time.perf_counter() - start_time) * 1000
|
|
415
|
+
get_metrics().increment_counter(
|
|
416
|
+
"asap_handler_errors_total",
|
|
417
|
+
{"payload_type": payload_type},
|
|
418
|
+
)
|
|
419
|
+
logger.exception(
|
|
420
|
+
"asap.handler.error",
|
|
421
|
+
payload_type=payload_type,
|
|
422
|
+
envelope_id=envelope.id,
|
|
423
|
+
error=str(e),
|
|
424
|
+
error_type=type(e).__name__,
|
|
425
|
+
duration_ms=round(duration_ms, 2),
|
|
426
|
+
)
|
|
427
|
+
raise
|
|
376
428
|
|
|
377
429
|
def list_handlers(self) -> list[str]:
|
|
378
430
|
"""List all registered payload types.
|
|
@@ -391,18 +443,19 @@ class HandlerRegistry:
|
|
|
391
443
|
return list(self._handlers.keys())
|
|
392
444
|
|
|
393
445
|
|
|
394
|
-
def create_echo_handler() ->
|
|
395
|
-
"""Create
|
|
446
|
+
def create_echo_handler() -> SyncHandler:
|
|
447
|
+
"""Create a synchronous echo handler that echoes TaskRequest input.
|
|
396
448
|
|
|
397
449
|
The echo handler is a simple implementation that:
|
|
398
450
|
- Receives a TaskRequest envelope
|
|
399
451
|
- Returns a TaskResponse with the input echoed back
|
|
400
452
|
- Preserves trace_id and sets correlation_id
|
|
401
453
|
|
|
454
|
+
Returns SyncHandler (not Handler) so tests can use it without casting.
|
|
402
455
|
This is useful for testing and as a base for custom handlers.
|
|
403
456
|
|
|
404
457
|
Returns:
|
|
405
|
-
|
|
458
|
+
SyncHandler that echoes TaskRequest input
|
|
406
459
|
|
|
407
460
|
Example:
|
|
408
461
|
>>> handler = create_echo_handler()
|