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
asap/transport/server.py CHANGED
@@ -40,22 +40,40 @@ Example:
40
40
  >>> # Run with: uvicorn asap.transport.server:app --host 0.0.0.0 --port 8000
41
41
  """
42
42
 
43
+ import importlib
43
44
  import json
44
45
  import os
46
+ import sys
47
+ import threading
45
48
  import time
49
+ import traceback
46
50
  from dataclasses import dataclass
47
- from typing import Any, Callable, TypeVar
51
+ from pathlib import Path
52
+ from typing import Any, Callable, TypeVar, cast
48
53
 
49
54
  from fastapi import FastAPI, HTTPException, Request
50
55
  from fastapi.responses import JSONResponse, PlainTextResponse
56
+ from opentelemetry import context
51
57
  from pydantic import ValidationError
52
58
  from slowapi.errors import RateLimitExceeded
53
59
 
54
- from asap.errors import ThreadPoolExhaustedError
60
+ from asap.errors import InvalidNonceError, InvalidTimestampError, ThreadPoolExhaustedError
55
61
  from asap.models.constants import MAX_REQUEST_SIZE
56
62
  from asap.models.entities import Capability, Endpoint, Manifest, Skill
57
63
  from asap.models.envelope import Envelope
58
- from asap.observability import get_logger, get_metrics
64
+ from asap.observability import (
65
+ get_logger,
66
+ get_metrics,
67
+ is_debug_log_mode,
68
+ is_debug_mode,
69
+ sanitize_for_logging,
70
+ )
71
+ from asap.observability.tracing import (
72
+ configure_tracing,
73
+ extract_and_activate_envelope_trace_context,
74
+ inject_envelope_trace_context,
75
+ )
76
+ from asap.utils.sanitization import sanitize_nonce
59
77
  from asap.transport.middleware import (
60
78
  AuthenticationMiddleware,
61
79
  BearerTokenValidator,
@@ -83,6 +101,16 @@ from asap.transport.jsonrpc import (
83
101
  JsonRpcRequest,
84
102
  JsonRpcResponse,
85
103
  )
104
+ from asap.transport.compression import (
105
+ decompress_payload,
106
+ get_supported_encodings,
107
+ )
108
+ from asap.transport.validators import (
109
+ InMemoryNonceStore,
110
+ NonceStore,
111
+ validate_envelope_nonce,
112
+ validate_envelope_timestamp,
113
+ )
86
114
 
87
115
  # Module logger
88
116
  logger = get_logger(__name__)
@@ -91,6 +119,72 @@ logger = get_logger(__name__)
91
119
  T = TypeVar("T")
92
120
  HandlerResult = tuple[T | None, JSONResponse | None]
93
121
 
122
+ # Environment variable to enable handler hot reload (development)
123
+ ENV_HOT_RELOAD = "ASAP_HOT_RELOAD"
124
+
125
+
126
+ class RegistryHolder:
127
+ """Mutable holder for HandlerRegistry to support hot reload.
128
+
129
+ When hot reload is enabled, a background thread watches handlers.py and
130
+ replaces the registry on file change so new handler code is used without
131
+ restarting the server.
132
+ """
133
+
134
+ def __init__(self, registry: HandlerRegistry) -> None:
135
+ self.registry = registry
136
+ self._executor: BoundedExecutor | None = None
137
+
138
+ def replace_registry(self, new_registry: HandlerRegistry) -> None:
139
+ """Replace the held registry (e.g. after reloading handlers module)."""
140
+ if self._executor is not None:
141
+ new_registry._executor = self._executor
142
+ self.registry = new_registry
143
+
144
+
145
+ def _run_handler_watcher(holder: RegistryHolder, handlers_path: str) -> None:
146
+ """Background thread: watch handlers_path and reload registry on change.
147
+
148
+ Graceful degradation: if watchfiles is not installed, hot reload is skipped
149
+ and the server runs normally without file watching.
150
+ """
151
+ try:
152
+ from watchfiles import watch
153
+ except ImportError:
154
+ logger.warning(
155
+ "asap.server.handler_watcher_skip",
156
+ path=handlers_path,
157
+ message="watchfiles not installed; hot reload disabled. Install with: pip install watchfiles",
158
+ )
159
+ return
160
+ try:
161
+ for changes in watch(handlers_path):
162
+ if not changes:
163
+ continue
164
+ try:
165
+ import asap.transport.handlers as handlers_module
166
+
167
+ importlib.reload(handlers_module)
168
+ new_registry = handlers_module.create_default_registry()
169
+ holder.replace_registry(new_registry)
170
+ logger.info(
171
+ "asap.server.handlers_reloaded",
172
+ path=handlers_path,
173
+ handlers=new_registry.list_handlers(),
174
+ )
175
+ except Exception as e:
176
+ logger.warning(
177
+ "asap.server.handlers_reload_failed",
178
+ path=handlers_path,
179
+ error=str(e),
180
+ )
181
+ except Exception as e:
182
+ logger.warning(
183
+ "asap.server.handler_watcher_stopped",
184
+ path=handlers_path,
185
+ error=str(e),
186
+ )
187
+
94
188
 
95
189
  @dataclass
96
190
  class RequestContext:
@@ -132,29 +226,32 @@ class ASAPRequestHandler:
132
226
  auth_middleware: Optional authentication middleware
133
227
 
134
228
  Example:
135
- >>> handler = ASAPRequestHandler(registry, manifest, auth_middleware)
229
+ >>> handler = ASAPRequestHandler(RegistryHolder(registry), manifest, auth_middleware)
136
230
  >>> response = await handler.handle_message(request)
137
231
  """
138
232
 
139
233
  def __init__(
140
234
  self,
141
- registry: HandlerRegistry,
235
+ registry_holder: RegistryHolder,
142
236
  manifest: Manifest,
143
237
  auth_middleware: AuthenticationMiddleware | None = None,
144
238
  max_request_size: int = MAX_REQUEST_SIZE,
239
+ nonce_store: NonceStore | None = None,
145
240
  ) -> None:
146
241
  """Initialize the request handler.
147
242
 
148
243
  Args:
149
- registry: Handler registry for dispatching payloads
244
+ registry_holder: Holder for handler registry (supports hot reload).
150
245
  manifest: Agent manifest describing capabilities
151
246
  auth_middleware: Optional authentication middleware for request validation
152
247
  max_request_size: Maximum allowed request size in bytes
248
+ nonce_store: Optional nonce store for replay attack prevention
153
249
  """
154
- self.registry = registry
250
+ self.registry_holder = registry_holder
155
251
  self.manifest = manifest
156
252
  self.auth_middleware = auth_middleware
157
253
  self.max_request_size = max_request_size
254
+ self.nonce_store = nonce_store
158
255
 
159
256
  def _normalize_payload_type_for_metrics(self, payload_type: str) -> str:
160
257
  """Normalize payload type for metrics to prevent cardinality explosion.
@@ -169,7 +266,7 @@ class ASAPRequestHandler:
169
266
  Returns:
170
267
  The payload type if registered, or "other" if unknown
171
268
  """
172
- if self.registry.has_handler(payload_type):
269
+ if self.registry_holder.registry.has_handler(payload_type):
173
270
  return payload_type
174
271
  return "other"
175
272
 
@@ -225,6 +322,23 @@ class ASAPRequestHandler:
225
322
  duration_seconds,
226
323
  {"payload_type": normalized_payload_type, "status": "error"},
227
324
  )
325
+ # Specific error counters for observability
326
+ if error_type == "parse_error":
327
+ metrics.increment_counter("asap_parse_errors_total")
328
+ elif error_type == "auth_failed":
329
+ metrics.increment_counter("asap_auth_failures_total")
330
+ elif error_type == "invalid_timestamp":
331
+ metrics.increment_counter("asap_invalid_timestamp_total")
332
+ elif error_type == "invalid_nonce":
333
+ metrics.increment_counter("asap_invalid_nonce_total")
334
+ elif error_type == "sender_mismatch":
335
+ metrics.increment_counter("asap_sender_mismatch_total")
336
+ elif error_type in (
337
+ "invalid_envelope",
338
+ "missing_envelope",
339
+ "invalid_params",
340
+ ):
341
+ metrics.increment_counter("asap_validation_errors_total", {"reason": error_type})
228
342
 
229
343
  def _validate_envelope(
230
344
  self,
@@ -242,7 +356,6 @@ class ASAPRequestHandler:
242
356
  Tuple of (Envelope, payload_type) if valid, or (None, error_response) if invalid
243
357
  """
244
358
  rpc_request = ctx.rpc_request
245
- # Validate params is a dict before accessing
246
359
  if not isinstance(rpc_request.params, dict):
247
360
  logger.warning(
248
361
  "asap.request.invalid_params_type",
@@ -281,17 +394,18 @@ class ASAPRequestHandler:
281
394
  )
282
395
  return None, error_response
283
396
 
284
- # Validate envelope structure
285
397
  try:
286
398
  envelope = Envelope(**envelope_data)
287
399
  payload_type = envelope.payload_type
288
400
  return envelope, payload_type
289
401
  except ValidationError as e:
290
- logger.warning(
291
- "asap.request.invalid_envelope",
292
- error="Invalid envelope structure",
293
- validation_errors=str(e.errors()),
294
- )
402
+ log_data: dict[str, Any] = {
403
+ "error": "Invalid envelope structure",
404
+ "validation_errors": e.errors(),
405
+ }
406
+ if not is_debug_mode():
407
+ log_data = sanitize_for_logging(log_data)
408
+ logger.warning("asap.request.invalid_envelope", **log_data)
295
409
  duration_seconds = time.perf_counter() - ctx.start_time
296
410
  self.record_error_metrics(ctx.metrics, "unknown", "invalid_envelope", duration_seconds)
297
411
  error_response = self.build_error_response(
@@ -324,7 +438,9 @@ class ASAPRequestHandler:
324
438
  """
325
439
  payload_type = envelope.payload_type
326
440
  try:
327
- response_envelope = await self.registry.dispatch_async(envelope, self.manifest)
441
+ response_envelope = await self.registry_holder.registry.dispatch_async(
442
+ envelope, self.manifest
443
+ )
328
444
  return response_envelope, payload_type
329
445
  except ThreadPoolExhaustedError as e:
330
446
  # Thread pool exhausted - service temporarily unavailable
@@ -480,6 +596,7 @@ class ASAPRequestHandler:
480
596
  Returns:
481
597
  JSON-RPC success response
482
598
  """
599
+ response_envelope = inject_envelope_trace_context(response_envelope)
483
600
  duration_seconds = time.perf_counter() - ctx.start_time
484
601
  duration_ms = duration_seconds * 1000
485
602
 
@@ -546,7 +663,7 @@ class ASAPRequestHandler:
546
663
  # Record error metrics (normalized to prevent cardinality explosion)
547
664
  self.record_error_metrics(ctx.metrics, payload_type, "internal_error", duration_seconds)
548
665
 
549
- # Log error
666
+ # Always log full error server-side for diagnostics
550
667
  logger.exception(
551
668
  "asap.request.error",
552
669
  error=str(error),
@@ -554,12 +671,18 @@ class ASAPRequestHandler:
554
671
  duration_ms=round(duration_ms, 2),
555
672
  )
556
673
 
557
- # Internal server error
674
+ # Production: generic error to client; debug: full error and stack trace
675
+ if is_debug_mode():
676
+ error_data: dict[str, Any] = {
677
+ "error": str(error),
678
+ "type": type(error).__name__,
679
+ "traceback": traceback.format_exc(),
680
+ }
681
+ else:
682
+ error_data = {"error": "Internal server error"}
683
+
558
684
  internal_error = JsonRpcErrorResponse(
559
- error=JsonRpcError.from_code(
560
- INTERNAL_ERROR,
561
- data={"error": str(error), "type": type(error).__name__},
562
- ),
685
+ error=JsonRpcError.from_code(INTERNAL_ERROR, data=error_data),
563
686
  id=ctx.request_id,
564
687
  )
565
688
  return JSONResponse(
@@ -567,6 +690,35 @@ class ASAPRequestHandler:
567
690
  content=internal_error.model_dump(),
568
691
  )
569
692
 
693
+ def _log_request_debug(self, rpc_request: JsonRpcRequest) -> None:
694
+ """Log full JSON-RPC request when ASAP_DEBUG_LOG is enabled (structured JSON)."""
695
+ if not is_debug_log_mode():
696
+ return
697
+ request_dict: dict[str, Any] = rpc_request.model_dump()
698
+ if not is_debug_mode():
699
+ request_dict = sanitize_for_logging(request_dict)
700
+ logger.info("asap.request.debug_request", request_json=request_dict)
701
+
702
+ def _log_response_debug(self, response: JSONResponse) -> None:
703
+ """Log full response when ASAP_DEBUG_LOG is enabled (structured JSON)."""
704
+ if not is_debug_log_mode():
705
+ return
706
+ try:
707
+ body_bytes = response.body
708
+ # Handle both bytes and memoryview
709
+ if isinstance(body_bytes, memoryview):
710
+ body_bytes = body_bytes.tobytes()
711
+ response_dict: dict[str, Any] = json.loads(body_bytes.decode("utf-8"))
712
+ except (ValueError, AttributeError):
713
+ response_dict = {"_raw": "(unable to decode response body)"}
714
+ if not is_debug_mode():
715
+ response_dict = sanitize_for_logging(response_dict)
716
+ logger.info(
717
+ "asap.request.debug_response",
718
+ status_code=response.status_code,
719
+ response_json=response_dict,
720
+ )
721
+
570
722
  async def _parse_and_validate_request(
571
723
  self,
572
724
  request: Request,
@@ -604,7 +756,6 @@ class ASAPRequestHandler:
604
756
  self.record_error_metrics(temp_metrics, "unknown", "parse_error", 0.0)
605
757
  return None, error_response
606
758
 
607
- # Validate body is a dict (JSON-RPC requires object at root)
608
759
  if not isinstance(body, dict):
609
760
  error_response = self.build_error_response(
610
761
  INVALID_REQUEST,
@@ -618,17 +769,13 @@ class ASAPRequestHandler:
618
769
  self.record_error_metrics(temp_metrics, "unknown", "invalid_request", 0.0)
619
770
  return None, error_response
620
771
 
621
- # Validate JSON-RPC request structure and method
622
772
  rpc_request, validation_error = self.validate_jsonrpc_request(body)
623
773
  if validation_error is not None:
624
774
  temp_metrics = get_metrics()
625
775
  self.record_error_metrics(temp_metrics, "unknown", "invalid_request", 0.0)
626
776
  return None, validation_error
627
777
 
628
- # Type narrowing: rpc_request is not None here
629
778
  if rpc_request is None:
630
- # This should not happen if validate_jsonrpc_request is correct
631
- # but guard against it for robustness
632
779
  error_response = self.build_error_response(
633
780
  INTERNAL_ERROR,
634
781
  data={"error": "Internal validation error"},
@@ -655,7 +802,6 @@ class ASAPRequestHandler:
655
802
  Raises:
656
803
  HTTPException: If request size exceeds maximum (413 Payload Too Large)
657
804
  """
658
- # Check Content-Length header first
659
805
  content_length = request.headers.get("content-length")
660
806
  if content_length:
661
807
  try:
@@ -671,14 +817,14 @@ class ASAPRequestHandler:
671
817
  detail=f"Request size ({size} bytes) exceeds maximum ({max_size} bytes)",
672
818
  )
673
819
  except ValueError:
674
- # Invalid Content-Length header, will check body size instead
675
820
  pass
676
821
 
677
822
  async def parse_json_body(self, request: Request) -> dict[str, Any]:
678
- """Parse JSON body from request with size validation.
823
+ """Parse JSON body from request with size validation and decompression.
679
824
 
680
825
  Validates request size before parsing to prevent DoS attacks.
681
826
  Checks both Content-Length header and actual body size.
827
+ Automatically decompresses gzip/brotli encoded requests.
682
828
 
683
829
  Args:
684
830
  request: FastAPI request object
@@ -688,16 +834,26 @@ class ASAPRequestHandler:
688
834
 
689
835
  Raises:
690
836
  HTTPException: If request size exceeds maximum (413)
837
+ HTTPException: If Content-Encoding is unsupported (415)
691
838
  ValueError: If JSON is invalid
692
839
  """
693
- # Read body in chunks and validate size incrementally to prevent OOM attacks
694
- # Note: Content-Length header validation is handled by SizeLimitMiddleware
695
- # This validates actual body size during streaming to prevent OOM attacks
840
+ content_encoding = request.headers.get("content-encoding", "").lower().strip()
841
+ supported_encodings = get_supported_encodings() + ["identity", ""]
842
+ if content_encoding and content_encoding not in supported_encodings:
843
+ logger.warning(
844
+ "asap.request.unsupported_encoding",
845
+ content_encoding=content_encoding,
846
+ supported=supported_encodings,
847
+ )
848
+ raise HTTPException(
849
+ status_code=415,
850
+ detail=f"Unsupported Content-Encoding: {content_encoding}. Supported: {', '.join(get_supported_encodings())}",
851
+ )
852
+
696
853
  try:
697
854
  body_bytes = bytearray()
698
855
  async for chunk in request.stream():
699
856
  body_bytes.extend(chunk)
700
- # Validate size after each chunk to abort early if limit exceeded
701
857
  if len(body_bytes) > self.max_request_size:
702
858
  logger.warning(
703
859
  "asap.request.size_exceeded",
@@ -709,6 +865,58 @@ class ASAPRequestHandler:
709
865
  detail=f"Request size ({len(body_bytes)} bytes) exceeds maximum ({self.max_request_size} bytes)",
710
866
  )
711
867
 
868
+ # Decompress if Content-Encoding is specified
869
+ if content_encoding and content_encoding not in ("identity", ""):
870
+ try:
871
+ compressed_size = len(body_bytes)
872
+ body_bytes = bytearray(decompress_payload(bytes(body_bytes), content_encoding))
873
+ decompressed_size = len(body_bytes)
874
+
875
+ logger.debug(
876
+ "asap.request.decompressed",
877
+ content_encoding=content_encoding,
878
+ compressed_size=compressed_size,
879
+ decompressed_size=decompressed_size,
880
+ )
881
+
882
+ if decompressed_size > self.max_request_size:
883
+ compression_ratio = (
884
+ decompressed_size / compressed_size if compressed_size > 0 else 0
885
+ )
886
+ logger.warning(
887
+ "asap.request.decompressed_size_exceeded",
888
+ decompressed_size=decompressed_size,
889
+ original_compressed_size=compressed_size,
890
+ compression_ratio=round(compression_ratio, 2),
891
+ max_size=self.max_request_size,
892
+ )
893
+ raise HTTPException(
894
+ status_code=413,
895
+ detail=f"Decompressed request size ({decompressed_size} bytes) exceeds maximum ({self.max_request_size} bytes)",
896
+ )
897
+ except ValueError as e:
898
+ # Decompression failed (invalid compressed data or unsupported encoding)
899
+ logger.warning(
900
+ "asap.request.decompression_failed",
901
+ content_encoding=content_encoding,
902
+ error=str(e),
903
+ )
904
+ raise HTTPException(
905
+ status_code=400,
906
+ detail=f"Failed to decompress request: {e}",
907
+ ) from e
908
+ except (OSError, EOFError) as e:
909
+ # Invalid gzip/brotli data (OSError) or truncated data (EOFError)
910
+ logger.warning(
911
+ "asap.request.invalid_compressed_data",
912
+ content_encoding=content_encoding,
913
+ error=str(e),
914
+ )
915
+ raise HTTPException(
916
+ status_code=400,
917
+ detail=f"Invalid compressed data: {e}",
918
+ ) from e
919
+
712
920
  # Parse JSON from bytes
713
921
  body: dict[str, Any] = json.loads(body_bytes.decode("utf-8"))
714
922
  return body
@@ -730,11 +938,9 @@ class ASAPRequestHandler:
730
938
  Returns:
731
939
  Tuple of (JsonRpcRequest, None) if valid, or (None, error_response) if invalid
732
940
  """
733
- # Validate JSON-RPC structure
734
941
  try:
735
942
  rpc_request = JsonRpcRequest(**body)
736
943
  except (ValidationError, TypeError) as e:
737
- # Check if error is specifically about params type
738
944
  error_code = INVALID_REQUEST
739
945
  error_message = "Invalid JSON-RPC structure"
740
946
  if isinstance(e, ValidationError):
@@ -746,12 +952,18 @@ class ASAPRequestHandler:
746
952
  error_message = "JSON-RPC 'params' must be an object"
747
953
  break
748
954
 
749
- logger.warning(
750
- "asap.request.invalid_structure",
751
- error=error_message,
752
- error_type=type(e).__name__,
753
- validation_errors=str(e.errors()) if isinstance(e, ValidationError) else str(e),
754
- )
955
+ log_struct: dict[str, Any] = {
956
+ "error": error_message,
957
+ "error_type": type(e).__name__,
958
+ "validation_errors": (
959
+ e.errors()
960
+ if isinstance(e, ValidationError)
961
+ else [{"type": "type_error", "loc": (), "msg": str(e), "input": None}]
962
+ ),
963
+ }
964
+ if not is_debug_mode():
965
+ log_struct = sanitize_for_logging(log_struct)
966
+ logger.warning("asap.request.invalid_structure", **log_struct)
755
967
  error_response = self.build_error_response(
756
968
  error_code,
757
969
  data={
@@ -766,7 +978,6 @@ class ASAPRequestHandler:
766
978
  )
767
979
  return None, error_response
768
980
 
769
- # Check method
770
981
  if rpc_request.method != ASAP_METHOD:
771
982
  logger.warning("asap.request.unknown_method", method=rpc_request.method)
772
983
  error_response = self.build_error_response(
@@ -802,15 +1013,16 @@ class ASAPRequestHandler:
802
1013
  payload_type = "unknown"
803
1014
 
804
1015
  try:
805
- # Parse and validate JSON-RPC request
806
1016
  parse_result = await self._parse_and_validate_request(request)
807
1017
  rpc_request, parse_error = parse_result
808
1018
  if parse_error is not None:
1019
+ self._log_response_debug(parse_error)
809
1020
  return parse_error
810
- # Type narrowing: rpc_request is not None here
811
- assert rpc_request is not None
1021
+ if rpc_request is None:
1022
+ raise RuntimeError("Internal error: rpc_request is None after validation")
1023
+
1024
+ self._log_request_debug(rpc_request)
812
1025
 
813
- # Create request context
814
1026
  ctx = RequestContext(
815
1027
  request_id=rpc_request.id,
816
1028
  start_time=start_time,
@@ -818,55 +1030,115 @@ class ASAPRequestHandler:
818
1030
  rpc_request=rpc_request,
819
1031
  )
820
1032
 
821
- # Authenticate request if enabled
822
1033
  auth_result = await self._authenticate_request(request, ctx)
823
1034
  authenticated_agent_id, auth_error = auth_result
824
1035
  if auth_error is not None:
1036
+ self._log_response_debug(auth_error)
825
1037
  return auth_error
826
1038
 
827
- # Validate and extract envelope
828
1039
  envelope_result = self._validate_envelope(ctx)
829
1040
  envelope_or_none, result = envelope_result
830
1041
  if envelope_or_none is None:
831
- # result is JSONResponse when envelope is None
832
- return result # type: ignore[return-value]
1042
+ error_resp = cast(JSONResponse, result)
1043
+ self._log_response_debug(error_resp)
1044
+ return error_resp
833
1045
  envelope = envelope_or_none
834
- # result is payload_type (str) when envelope is not None
835
- payload_type = result # type: ignore[assignment]
836
-
837
- # Verify sender matches authenticated identity
838
- sender_error = self._verify_sender_matches_auth(
839
- authenticated_agent_id,
840
- envelope,
841
- ctx,
842
- payload_type,
843
- )
844
- if sender_error is not None:
845
- return sender_error
1046
+ payload_type = cast(str, result)
846
1047
 
847
- # Log request received
848
- logger.info(
849
- "asap.request.received",
850
- envelope_id=envelope.id,
851
- trace_id=envelope.trace_id,
852
- payload_type=envelope.payload_type,
853
- sender=envelope.sender,
854
- recipient=envelope.recipient,
855
- authenticated=authenticated_agent_id is not None,
856
- )
857
-
858
- # Dispatch to handler
859
- dispatch_result = await self._dispatch_to_handler(envelope, ctx)
860
- response_or_none, result = dispatch_result
861
- if response_or_none is None:
862
- # result is JSONResponse when response is None
863
- return result # type: ignore[return-value]
864
- response_envelope = response_or_none
865
- # result is payload_type (str) when response is not None
866
- payload_type = result # type: ignore[assignment]
1048
+ trace_token = extract_and_activate_envelope_trace_context(envelope)
1049
+ try:
1050
+ sender_error = self._verify_sender_matches_auth(
1051
+ authenticated_agent_id,
1052
+ envelope,
1053
+ ctx,
1054
+ payload_type,
1055
+ )
1056
+ if sender_error is not None:
1057
+ self._log_response_debug(sender_error)
1058
+ return sender_error
1059
+
1060
+ try:
1061
+ validate_envelope_timestamp(envelope)
1062
+ except InvalidTimestampError as e:
1063
+ log_ts: dict[str, Any] = {
1064
+ "envelope_id": envelope.id,
1065
+ "error": e.message,
1066
+ "details": e.details,
1067
+ }
1068
+ if not is_debug_mode() and isinstance(e.details, dict):
1069
+ log_ts["details"] = sanitize_for_logging(e.details)
1070
+ logger.warning("asap.request.invalid_timestamp", **log_ts)
1071
+ duration_seconds = time.perf_counter() - ctx.start_time
1072
+ self.record_error_metrics(
1073
+ ctx.metrics, payload_type, "invalid_timestamp", duration_seconds
1074
+ )
1075
+ err_resp = self.build_error_response(
1076
+ INVALID_PARAMS,
1077
+ data={
1078
+ "error": "Invalid envelope timestamp",
1079
+ "code": e.code,
1080
+ "message": e.message,
1081
+ "details": e.details,
1082
+ },
1083
+ request_id=ctx.request_id,
1084
+ )
1085
+ self._log_response_debug(err_resp)
1086
+ return err_resp
1087
+
1088
+ try:
1089
+ validate_envelope_nonce(envelope, self.nonce_store)
1090
+ except InvalidNonceError as e:
1091
+ # Sanitize nonce in logs to prevent full value exposure
1092
+ nonce_sanitized = sanitize_nonce(e.nonce)
1093
+ error_msg = e.message if is_debug_mode() else "Duplicate nonce detected"
1094
+ logger.warning(
1095
+ "asap.request.invalid_nonce",
1096
+ envelope_id=envelope.id,
1097
+ nonce=nonce_sanitized,
1098
+ error=error_msg,
1099
+ )
1100
+ duration_seconds = time.perf_counter() - ctx.start_time
1101
+ self.record_error_metrics(
1102
+ ctx.metrics, payload_type, "invalid_nonce", duration_seconds
1103
+ )
1104
+ err_resp = self.build_error_response(
1105
+ INVALID_PARAMS,
1106
+ data={
1107
+ "error": "Invalid envelope nonce",
1108
+ "code": e.code,
1109
+ "message": e.message,
1110
+ "details": e.details,
1111
+ },
1112
+ request_id=ctx.request_id,
1113
+ )
1114
+ self._log_response_debug(err_resp)
1115
+ return err_resp
1116
+
1117
+ logger.info(
1118
+ "asap.request.received",
1119
+ envelope_id=envelope.id,
1120
+ trace_id=envelope.trace_id,
1121
+ payload_type=envelope.payload_type,
1122
+ sender=envelope.sender,
1123
+ recipient=envelope.recipient,
1124
+ authenticated=authenticated_agent_id is not None,
1125
+ )
867
1126
 
868
- # Build and return success response
869
- return self._build_success_response(response_envelope, ctx, payload_type)
1127
+ dispatch_result = await self._dispatch_to_handler(envelope, ctx)
1128
+ response_or_none, result = dispatch_result
1129
+ if response_or_none is None:
1130
+ error_resp = cast(JSONResponse, result)
1131
+ self._log_response_debug(error_resp)
1132
+ return error_resp
1133
+ response_envelope = response_or_none
1134
+ payload_type = cast(str, result)
1135
+
1136
+ success_resp = self._build_success_response(response_envelope, ctx, payload_type)
1137
+ self._log_response_debug(success_resp)
1138
+ return success_resp
1139
+ finally:
1140
+ if trace_token is not None:
1141
+ context.detach(trace_token)
870
1142
 
871
1143
  except Exception as e:
872
1144
  # Create minimal context for error handling if we don't have rpc_request yet
@@ -883,7 +1155,9 @@ class ASAPRequestHandler:
883
1155
  metrics=temp_metrics,
884
1156
  rpc_request=temp_rpc_request,
885
1157
  )
886
- return self._handle_internal_error(e, ctx, payload_type)
1158
+ err_resp = self._handle_internal_error(e, ctx, payload_type)
1159
+ self._log_response_debug(err_resp)
1160
+ return err_resp
887
1161
 
888
1162
 
889
1163
  def create_app(
@@ -893,6 +1167,8 @@ def create_app(
893
1167
  rate_limit: str | None = None,
894
1168
  max_request_size: int | None = None,
895
1169
  max_threads: int | None = None,
1170
+ require_nonce: bool = False,
1171
+ hot_reload: bool | None = None,
896
1172
  ) -> FastAPI:
897
1173
  """Create and configure a FastAPI application for ASAP protocol.
898
1174
 
@@ -912,14 +1188,24 @@ def create_app(
912
1188
  token_validator: Optional function to validate Bearer tokens.
913
1189
  Required if manifest.auth is configured. Should return agent ID
914
1190
  if token is valid, None otherwise.
915
- rate_limit: Optional rate limit string (e.g., "100/minute").
1191
+ rate_limit: Optional rate limit string (e.g., "10/second;100/minute").
916
1192
  Rate limiting is IP-based (per client IP address) to prevent DoS attacks.
917
- Defaults to ASAP_RATE_LIMIT environment variable or "100/minute".
1193
+ Uses token bucket pattern: burst limit + sustained limit.
1194
+ Defaults to ASAP_RATE_LIMIT environment variable or "10/second;100/minute".
1195
+ **Warning:** The default storage is ``memory://`` (per-process). In
1196
+ multi-worker deployments (e.g., Gunicorn with 4 workers), each worker
1197
+ has isolated limits, so effective rate = limit × workers (e.g.,
1198
+ 10/s → 40/s). For production, use Redis-backed storage via slowapi.
918
1199
  max_request_size: Optional maximum request size in bytes.
919
1200
  Defaults to ASAP_MAX_REQUEST_SIZE environment variable or 10MB.
920
1201
  max_threads: Optional maximum number of threads for sync handlers.
921
1202
  Defaults to ASAP_MAX_THREADS environment variable or min(32, cpu_count + 4).
922
1203
  Set to None to use unbounded executor (not recommended for production).
1204
+ require_nonce: If True, enables nonce validation for replay attack prevention.
1205
+ When enabled, creates an InMemoryNonceStore and validates nonces in envelopes.
1206
+ Defaults to False (nonce validation is optional).
1207
+ hot_reload: If True, watch handlers.py and reload handler registry on file change
1208
+ (development only). Defaults to ASAP_HOT_RELOAD env or False.
923
1209
 
924
1210
  Returns:
925
1211
  Configured FastAPI application ready to run
@@ -976,6 +1262,7 @@ def create_app(
976
1262
  )
977
1263
 
978
1264
  # Use default registry if none provided
1265
+ use_default_registry = registry is None
979
1266
  if registry is None:
980
1267
  registry = create_default_registry()
981
1268
 
@@ -983,6 +1270,15 @@ def create_app(
983
1270
  if executor is not None:
984
1271
  registry._executor = executor
985
1272
 
1273
+ # Wrap registry in holder for hot reload support
1274
+ registry_holder = RegistryHolder(registry)
1275
+ if executor is not None:
1276
+ registry_holder._executor = executor
1277
+
1278
+ # Resolve hot_reload from env if not specified
1279
+ if hot_reload is None:
1280
+ hot_reload = os.getenv(ENV_HOT_RELOAD, "").strip().lower() in ("true", "1", "yes")
1281
+
986
1282
  # Create authentication middleware if auth is configured
987
1283
  auth_middleware: AuthenticationMiddleware | None = None
988
1284
  if manifest.auth is not None:
@@ -1003,13 +1299,55 @@ def create_app(
1003
1299
  if max_request_size is None:
1004
1300
  max_request_size = int(os.getenv("ASAP_MAX_REQUEST_SIZE", str(MAX_REQUEST_SIZE)))
1005
1301
 
1302
+ # Create nonce store if required
1303
+ nonce_store: NonceStore | None = None
1304
+ if require_nonce:
1305
+ nonce_store = InMemoryNonceStore()
1306
+ logger.info(
1307
+ "asap.server.nonce_validation_enabled",
1308
+ manifest_id=manifest.id,
1309
+ )
1310
+
1006
1311
  # Create request handler
1007
- handler = ASAPRequestHandler(registry, manifest, auth_middleware, max_request_size)
1312
+ handler = ASAPRequestHandler(
1313
+ registry_holder, manifest, auth_middleware, max_request_size, nonce_store
1314
+ )
1315
+
1316
+ # Start handler file watcher when hot reload is enabled (only with default registry)
1317
+ if hot_reload and use_default_registry:
1318
+ _handlers_module = sys.modules.get("asap.transport.handlers")
1319
+ _handlers_file = getattr(_handlers_module, "__file__", "") if _handlers_module else ""
1320
+ if _handlers_file and Path(_handlers_file).exists():
1321
+ watcher = threading.Thread(
1322
+ target=_run_handler_watcher,
1323
+ args=(registry_holder, _handlers_file),
1324
+ name="asap-handler-watcher",
1325
+ daemon=True,
1326
+ )
1327
+ watcher.start()
1328
+ logger.info(
1329
+ "asap.server.hot_reload_enabled",
1330
+ manifest_id=manifest.id,
1331
+ path=_handlers_file,
1332
+ )
1333
+ else:
1334
+ logger.warning(
1335
+ "asap.server.hot_reload_skipped",
1336
+ reason="handlers module path not found",
1337
+ )
1338
+
1339
+ # Enable Swagger UI (/docs) and ReDoc (/redoc) only when ASAP_DEBUG=true
1340
+ _docs_url = "/docs" if is_debug_mode() else None
1341
+ _redoc_url = "/redoc" if is_debug_mode() else None
1342
+ _openapi_url = "/openapi.json" if is_debug_mode() else None
1008
1343
 
1009
1344
  app = FastAPI(
1010
1345
  title="ASAP Protocol Server",
1011
1346
  description=f"ASAP server for {manifest.name}",
1012
1347
  version=manifest.version,
1348
+ docs_url=_docs_url,
1349
+ redoc_url=_redoc_url,
1350
+ openapi_url=_openapi_url,
1013
1351
  )
1014
1352
 
1015
1353
  # Add size limit middleware (runs before routing)
@@ -1017,7 +1355,8 @@ def create_app(
1017
1355
 
1018
1356
  # Configure rate limiting
1019
1357
  if rate_limit is None:
1020
- rate_limit_str = os.getenv("ASAP_RATE_LIMIT", "100/minute")
1358
+ # Default matches DD-012: Burst allowance for better UX with bursty agent traffic
1359
+ rate_limit_str = os.getenv("ASAP_RATE_LIMIT", "10/second;100/minute")
1021
1360
  else:
1022
1361
  rate_limit_str = rate_limit
1023
1362
 
@@ -1038,10 +1377,35 @@ def create_app(
1038
1377
  max_request_size=max_request_size,
1039
1378
  )
1040
1379
 
1041
- # Note: Request size limits should be configured at the ASGI server level (e.g., uvicorn).
1042
- # For production, consider setting --limit-max-requests or using a reverse proxy
1380
+ # Note: Request size limits should be configured at the ASGI server level
1381
+ # (e.g., uvicorn --limit-max-body).
1382
+ # For production, consider using a reverse proxy
1043
1383
  # (nginx, traefik) to enforce request size limits (e.g., 10MB max).
1044
1384
 
1385
+ @app.get("/health")
1386
+ async def health() -> JSONResponse:
1387
+ """Liveness probe: always OK if the process is running.
1388
+
1389
+ Used by Kubernetes livenessProbe and Docker HEALTHCHECK.
1390
+ Returns 200 with {"status": "ok"}.
1391
+
1392
+ Returns:
1393
+ JSONResponse with status ok
1394
+ """
1395
+ return JSONResponse(status_code=200, content={"status": "ok"})
1396
+
1397
+ @app.get("/ready")
1398
+ async def ready() -> JSONResponse:
1399
+ """Readiness probe: OK when the server is ready to accept traffic.
1400
+
1401
+ Used by Kubernetes readinessProbe. Returns 200 when the app
1402
+ is initialized and can serve requests.
1403
+
1404
+ Returns:
1405
+ JSONResponse with status ok
1406
+ """
1407
+ return JSONResponse(status_code=200, content={"status": "ok"})
1408
+
1045
1409
  @app.get("/.well-known/asap/manifest.json")
1046
1410
  async def get_manifest() -> dict[str, Any]:
1047
1411
  """Return the agent's manifest for discovery.
@@ -1075,9 +1439,12 @@ def create_app(
1075
1439
  metrics = get_metrics()
1076
1440
  return PlainTextResponse(
1077
1441
  content=metrics.export_prometheus(),
1078
- media_type="text/plain; version=0.0.4; charset=utf-8",
1442
+ media_type="application/openmetrics-text; version=1.0.0; charset=utf-8",
1079
1443
  )
1080
1444
 
1445
+ # OpenTelemetry tracing (zero-config via OTEL_* env vars)
1446
+ configure_tracing(service_name=manifest.id, app=app)
1447
+
1081
1448
  @app.post("/asap")
1082
1449
  @limiter.limit(rate_limit_str) # slowapi uses app.state.limiter at runtime
1083
1450
  async def handle_asap_message(request: Request) -> JSONResponse:
@@ -1117,7 +1484,7 @@ def _create_default_manifest() -> Manifest:
1117
1484
  return Manifest(
1118
1485
  id="urn:asap:agent:default-server",
1119
1486
  name="ASAP Default Server",
1120
- version="0.3.0",
1487
+ version="1.0.0-dev",
1121
1488
  description="Default ASAP protocol server with echo capabilities",
1122
1489
  capabilities=Capability(
1123
1490
  asap_version="0.1",