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.
Files changed (62) hide show
  1. asap/__init__.py +1 -1
  2. asap/cli.py +137 -2
  3. asap/errors.py +167 -0
  4. asap/examples/README.md +81 -10
  5. asap/examples/auth_patterns.py +212 -0
  6. asap/examples/error_recovery.py +248 -0
  7. asap/examples/long_running.py +287 -0
  8. asap/examples/mcp_integration.py +240 -0
  9. asap/examples/multi_step_workflow.py +134 -0
  10. asap/examples/orchestration.py +293 -0
  11. asap/examples/rate_limiting.py +137 -0
  12. asap/examples/run_demo.py +9 -4
  13. asap/examples/secure_handler.py +84 -0
  14. asap/examples/state_migration.py +240 -0
  15. asap/examples/streaming_response.py +108 -0
  16. asap/examples/websocket_concept.py +129 -0
  17. asap/mcp/__init__.py +43 -0
  18. asap/mcp/client.py +224 -0
  19. asap/mcp/protocol.py +179 -0
  20. asap/mcp/server.py +333 -0
  21. asap/mcp/server_runner.py +40 -0
  22. asap/models/__init__.py +4 -0
  23. asap/models/base.py +0 -3
  24. asap/models/constants.py +76 -1
  25. asap/models/entities.py +58 -7
  26. asap/models/envelope.py +14 -1
  27. asap/models/ids.py +8 -4
  28. asap/models/parts.py +33 -3
  29. asap/models/validators.py +16 -0
  30. asap/observability/__init__.py +6 -0
  31. asap/observability/dashboards/README.md +24 -0
  32. asap/observability/dashboards/asap-detailed.json +131 -0
  33. asap/observability/dashboards/asap-red.json +129 -0
  34. asap/observability/logging.py +81 -1
  35. asap/observability/metrics.py +15 -1
  36. asap/observability/trace_parser.py +238 -0
  37. asap/observability/trace_ui.py +218 -0
  38. asap/observability/tracing.py +293 -0
  39. asap/state/machine.py +15 -2
  40. asap/state/snapshot.py +0 -9
  41. asap/testing/__init__.py +31 -0
  42. asap/testing/assertions.py +108 -0
  43. asap/testing/fixtures.py +113 -0
  44. asap/testing/mocks.py +152 -0
  45. asap/transport/__init__.py +31 -0
  46. asap/transport/cache.py +180 -0
  47. asap/transport/circuit_breaker.py +194 -0
  48. asap/transport/client.py +989 -72
  49. asap/transport/compression.py +389 -0
  50. asap/transport/handlers.py +106 -53
  51. asap/transport/middleware.py +64 -39
  52. asap/transport/server.py +461 -94
  53. asap/transport/validators.py +320 -0
  54. asap/utils/__init__.py +7 -0
  55. asap/utils/sanitization.py +134 -0
  56. asap_protocol-1.0.0.dist-info/METADATA +264 -0
  57. asap_protocol-1.0.0.dist-info/RECORD +70 -0
  58. asap_protocol-0.3.0.dist-info/METADATA +0 -227
  59. asap_protocol-0.3.0.dist-info/RECORD +0 -37
  60. {asap_protocol-0.3.0.dist-info → asap_protocol-1.0.0.dist-info}/WHEEL +0 -0
  61. {asap_protocol-0.3.0.dist-info → asap_protocol-1.0.0.dist-info}/entry_points.txt +0 -0
  62. {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
@@ -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
- # Execute handler outside the lock to allow concurrent dispatches
335
- try:
336
- # Support both sync and async handlers
337
- response: Envelope
338
- if inspect.iscoroutinefunction(handler):
339
- # Async handler - await it directly
340
- response = await handler(envelope, manifest)
341
- else:
342
- # Sync handler - run in thread pool to avoid blocking event loop
343
- # Also handle async callable objects that return awaitables
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
- # Type narrowing: result is Envelope for sync handlers
353
- # After checking it's not awaitable, we know it's Envelope
354
- response = cast(Envelope, result)
355
-
356
- duration_ms = (time.perf_counter() - start_time) * 1000
357
- logger.debug(
358
- "asap.handler.completed",
359
- payload_type=payload_type,
360
- envelope_id=envelope.id,
361
- response_id=response.id,
362
- duration_ms=round(duration_ms, 2),
363
- )
364
- return response
365
- except Exception as e:
366
- duration_ms = (time.perf_counter() - start_time) * 1000
367
- logger.exception(
368
- "asap.handler.error",
369
- payload_type=payload_type,
370
- envelope_id=envelope.id,
371
- error=str(e),
372
- error_type=type(e).__name__,
373
- duration_ms=round(duration_ms, 2),
374
- )
375
- raise
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() -> Handler:
395
- """Create an echo handler that echoes TaskRequest input.
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
- Handler function that echoes TaskRequest input
458
+ SyncHandler that echoes TaskRequest input
406
459
 
407
460
  Example:
408
461
  >>> handler = create_echo_handler()