asap-protocol 0.5.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 (59) hide show
  1. asap/__init__.py +1 -1
  2. asap/cli.py +137 -2
  3. asap/examples/README.md +81 -13
  4. asap/examples/auth_patterns.py +212 -0
  5. asap/examples/error_recovery.py +248 -0
  6. asap/examples/long_running.py +287 -0
  7. asap/examples/mcp_integration.py +240 -0
  8. asap/examples/multi_step_workflow.py +134 -0
  9. asap/examples/orchestration.py +293 -0
  10. asap/examples/rate_limiting.py +137 -0
  11. asap/examples/run_demo.py +0 -2
  12. asap/examples/secure_handler.py +84 -0
  13. asap/examples/state_migration.py +240 -0
  14. asap/examples/streaming_response.py +108 -0
  15. asap/examples/websocket_concept.py +129 -0
  16. asap/mcp/__init__.py +43 -0
  17. asap/mcp/client.py +224 -0
  18. asap/mcp/protocol.py +179 -0
  19. asap/mcp/server.py +333 -0
  20. asap/mcp/server_runner.py +40 -0
  21. asap/models/base.py +0 -3
  22. asap/models/constants.py +3 -1
  23. asap/models/entities.py +21 -6
  24. asap/models/envelope.py +7 -0
  25. asap/models/ids.py +8 -4
  26. asap/models/parts.py +33 -3
  27. asap/models/validators.py +16 -0
  28. asap/observability/__init__.py +6 -0
  29. asap/observability/dashboards/README.md +24 -0
  30. asap/observability/dashboards/asap-detailed.json +131 -0
  31. asap/observability/dashboards/asap-red.json +129 -0
  32. asap/observability/logging.py +81 -1
  33. asap/observability/metrics.py +15 -1
  34. asap/observability/trace_parser.py +238 -0
  35. asap/observability/trace_ui.py +218 -0
  36. asap/observability/tracing.py +293 -0
  37. asap/state/machine.py +15 -2
  38. asap/state/snapshot.py +0 -9
  39. asap/testing/__init__.py +31 -0
  40. asap/testing/assertions.py +108 -0
  41. asap/testing/fixtures.py +113 -0
  42. asap/testing/mocks.py +152 -0
  43. asap/transport/__init__.py +28 -0
  44. asap/transport/cache.py +180 -0
  45. asap/transport/circuit_breaker.py +9 -8
  46. asap/transport/client.py +418 -36
  47. asap/transport/compression.py +389 -0
  48. asap/transport/handlers.py +106 -53
  49. asap/transport/middleware.py +58 -34
  50. asap/transport/server.py +429 -139
  51. asap/transport/validators.py +0 -4
  52. asap/utils/sanitization.py +0 -5
  53. asap_protocol-1.0.0.dist-info/METADATA +264 -0
  54. asap_protocol-1.0.0.dist-info/RECORD +70 -0
  55. asap_protocol-0.5.0.dist-info/METADATA +0 -244
  56. asap_protocol-0.5.0.dist-info/RECORD +0 -41
  57. {asap_protocol-0.5.0.dist-info → asap_protocol-1.0.0.dist-info}/WHEEL +0 -0
  58. {asap_protocol-0.5.0.dist-info → asap_protocol-1.0.0.dist-info}/entry_points.txt +0 -0
  59. {asap_protocol-0.5.0.dist-info → asap_protocol-1.0.0.dist-info}/licenses/LICENSE +0 -0
asap/transport/server.py CHANGED
@@ -40,14 +40,20 @@ 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
 
@@ -55,7 +61,18 @@ from asap.errors import InvalidNonceError, InvalidTimestampError, ThreadPoolExha
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
+ )
59
76
  from asap.utils.sanitization import sanitize_nonce
60
77
  from asap.transport.middleware import (
61
78
  AuthenticationMiddleware,
@@ -84,6 +101,10 @@ from asap.transport.jsonrpc import (
84
101
  JsonRpcRequest,
85
102
  JsonRpcResponse,
86
103
  )
104
+ from asap.transport.compression import (
105
+ decompress_payload,
106
+ get_supported_encodings,
107
+ )
87
108
  from asap.transport.validators import (
88
109
  InMemoryNonceStore,
89
110
  NonceStore,
@@ -98,6 +119,72 @@ logger = get_logger(__name__)
98
119
  T = TypeVar("T")
99
120
  HandlerResult = tuple[T | None, JSONResponse | None]
100
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
+
101
188
 
102
189
  @dataclass
103
190
  class RequestContext:
@@ -139,13 +226,13 @@ class ASAPRequestHandler:
139
226
  auth_middleware: Optional authentication middleware
140
227
 
141
228
  Example:
142
- >>> handler = ASAPRequestHandler(registry, manifest, auth_middleware)
229
+ >>> handler = ASAPRequestHandler(RegistryHolder(registry), manifest, auth_middleware)
143
230
  >>> response = await handler.handle_message(request)
144
231
  """
145
232
 
146
233
  def __init__(
147
234
  self,
148
- registry: HandlerRegistry,
235
+ registry_holder: RegistryHolder,
149
236
  manifest: Manifest,
150
237
  auth_middleware: AuthenticationMiddleware | None = None,
151
238
  max_request_size: int = MAX_REQUEST_SIZE,
@@ -154,13 +241,13 @@ class ASAPRequestHandler:
154
241
  """Initialize the request handler.
155
242
 
156
243
  Args:
157
- registry: Handler registry for dispatching payloads
244
+ registry_holder: Holder for handler registry (supports hot reload).
158
245
  manifest: Agent manifest describing capabilities
159
246
  auth_middleware: Optional authentication middleware for request validation
160
247
  max_request_size: Maximum allowed request size in bytes
161
248
  nonce_store: Optional nonce store for replay attack prevention
162
249
  """
163
- self.registry = registry
250
+ self.registry_holder = registry_holder
164
251
  self.manifest = manifest
165
252
  self.auth_middleware = auth_middleware
166
253
  self.max_request_size = max_request_size
@@ -179,7 +266,7 @@ class ASAPRequestHandler:
179
266
  Returns:
180
267
  The payload type if registered, or "other" if unknown
181
268
  """
182
- if self.registry.has_handler(payload_type):
269
+ if self.registry_holder.registry.has_handler(payload_type):
183
270
  return payload_type
184
271
  return "other"
185
272
 
@@ -235,6 +322,23 @@ class ASAPRequestHandler:
235
322
  duration_seconds,
236
323
  {"payload_type": normalized_payload_type, "status": "error"},
237
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})
238
342
 
239
343
  def _validate_envelope(
240
344
  self,
@@ -252,7 +356,6 @@ class ASAPRequestHandler:
252
356
  Tuple of (Envelope, payload_type) if valid, or (None, error_response) if invalid
253
357
  """
254
358
  rpc_request = ctx.rpc_request
255
- # Validate params is a dict before accessing
256
359
  if not isinstance(rpc_request.params, dict):
257
360
  logger.warning(
258
361
  "asap.request.invalid_params_type",
@@ -291,17 +394,18 @@ class ASAPRequestHandler:
291
394
  )
292
395
  return None, error_response
293
396
 
294
- # Validate envelope structure
295
397
  try:
296
398
  envelope = Envelope(**envelope_data)
297
399
  payload_type = envelope.payload_type
298
400
  return envelope, payload_type
299
401
  except ValidationError as e:
300
- logger.warning(
301
- "asap.request.invalid_envelope",
302
- error="Invalid envelope structure",
303
- validation_errors=str(e.errors()),
304
- )
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)
305
409
  duration_seconds = time.perf_counter() - ctx.start_time
306
410
  self.record_error_metrics(ctx.metrics, "unknown", "invalid_envelope", duration_seconds)
307
411
  error_response = self.build_error_response(
@@ -334,7 +438,9 @@ class ASAPRequestHandler:
334
438
  """
335
439
  payload_type = envelope.payload_type
336
440
  try:
337
- 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
+ )
338
444
  return response_envelope, payload_type
339
445
  except ThreadPoolExhaustedError as e:
340
446
  # Thread pool exhausted - service temporarily unavailable
@@ -490,6 +596,7 @@ class ASAPRequestHandler:
490
596
  Returns:
491
597
  JSON-RPC success response
492
598
  """
599
+ response_envelope = inject_envelope_trace_context(response_envelope)
493
600
  duration_seconds = time.perf_counter() - ctx.start_time
494
601
  duration_ms = duration_seconds * 1000
495
602
 
@@ -556,7 +663,7 @@ class ASAPRequestHandler:
556
663
  # Record error metrics (normalized to prevent cardinality explosion)
557
664
  self.record_error_metrics(ctx.metrics, payload_type, "internal_error", duration_seconds)
558
665
 
559
- # Log error
666
+ # Always log full error server-side for diagnostics
560
667
  logger.exception(
561
668
  "asap.request.error",
562
669
  error=str(error),
@@ -564,12 +671,18 @@ class ASAPRequestHandler:
564
671
  duration_ms=round(duration_ms, 2),
565
672
  )
566
673
 
567
- # 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
+
568
684
  internal_error = JsonRpcErrorResponse(
569
- error=JsonRpcError.from_code(
570
- INTERNAL_ERROR,
571
- data={"error": str(error), "type": type(error).__name__},
572
- ),
685
+ error=JsonRpcError.from_code(INTERNAL_ERROR, data=error_data),
573
686
  id=ctx.request_id,
574
687
  )
575
688
  return JSONResponse(
@@ -577,6 +690,35 @@ class ASAPRequestHandler:
577
690
  content=internal_error.model_dump(),
578
691
  )
579
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
+
580
722
  async def _parse_and_validate_request(
581
723
  self,
582
724
  request: Request,
@@ -614,7 +756,6 @@ class ASAPRequestHandler:
614
756
  self.record_error_metrics(temp_metrics, "unknown", "parse_error", 0.0)
615
757
  return None, error_response
616
758
 
617
- # Validate body is a dict (JSON-RPC requires object at root)
618
759
  if not isinstance(body, dict):
619
760
  error_response = self.build_error_response(
620
761
  INVALID_REQUEST,
@@ -628,17 +769,13 @@ class ASAPRequestHandler:
628
769
  self.record_error_metrics(temp_metrics, "unknown", "invalid_request", 0.0)
629
770
  return None, error_response
630
771
 
631
- # Validate JSON-RPC request structure and method
632
772
  rpc_request, validation_error = self.validate_jsonrpc_request(body)
633
773
  if validation_error is not None:
634
774
  temp_metrics = get_metrics()
635
775
  self.record_error_metrics(temp_metrics, "unknown", "invalid_request", 0.0)
636
776
  return None, validation_error
637
777
 
638
- # Type narrowing: rpc_request is not None here
639
778
  if rpc_request is None:
640
- # This should not happen if validate_jsonrpc_request is correct
641
- # but guard against it for robustness
642
779
  error_response = self.build_error_response(
643
780
  INTERNAL_ERROR,
644
781
  data={"error": "Internal validation error"},
@@ -665,7 +802,6 @@ class ASAPRequestHandler:
665
802
  Raises:
666
803
  HTTPException: If request size exceeds maximum (413 Payload Too Large)
667
804
  """
668
- # Check Content-Length header first
669
805
  content_length = request.headers.get("content-length")
670
806
  if content_length:
671
807
  try:
@@ -681,14 +817,14 @@ class ASAPRequestHandler:
681
817
  detail=f"Request size ({size} bytes) exceeds maximum ({max_size} bytes)",
682
818
  )
683
819
  except ValueError:
684
- # Invalid Content-Length header, will check body size instead
685
820
  pass
686
821
 
687
822
  async def parse_json_body(self, request: Request) -> dict[str, Any]:
688
- """Parse JSON body from request with size validation.
823
+ """Parse JSON body from request with size validation and decompression.
689
824
 
690
825
  Validates request size before parsing to prevent DoS attacks.
691
826
  Checks both Content-Length header and actual body size.
827
+ Automatically decompresses gzip/brotli encoded requests.
692
828
 
693
829
  Args:
694
830
  request: FastAPI request object
@@ -698,16 +834,26 @@ class ASAPRequestHandler:
698
834
 
699
835
  Raises:
700
836
  HTTPException: If request size exceeds maximum (413)
837
+ HTTPException: If Content-Encoding is unsupported (415)
701
838
  ValueError: If JSON is invalid
702
839
  """
703
- # Read body in chunks and validate size incrementally to prevent OOM attacks
704
- # Note: Content-Length header validation is handled by SizeLimitMiddleware
705
- # 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
+
706
853
  try:
707
854
  body_bytes = bytearray()
708
855
  async for chunk in request.stream():
709
856
  body_bytes.extend(chunk)
710
- # Validate size after each chunk to abort early if limit exceeded
711
857
  if len(body_bytes) > self.max_request_size:
712
858
  logger.warning(
713
859
  "asap.request.size_exceeded",
@@ -719,6 +865,58 @@ class ASAPRequestHandler:
719
865
  detail=f"Request size ({len(body_bytes)} bytes) exceeds maximum ({self.max_request_size} bytes)",
720
866
  )
721
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
+
722
920
  # Parse JSON from bytes
723
921
  body: dict[str, Any] = json.loads(body_bytes.decode("utf-8"))
724
922
  return body
@@ -740,11 +938,9 @@ class ASAPRequestHandler:
740
938
  Returns:
741
939
  Tuple of (JsonRpcRequest, None) if valid, or (None, error_response) if invalid
742
940
  """
743
- # Validate JSON-RPC structure
744
941
  try:
745
942
  rpc_request = JsonRpcRequest(**body)
746
943
  except (ValidationError, TypeError) as e:
747
- # Check if error is specifically about params type
748
944
  error_code = INVALID_REQUEST
749
945
  error_message = "Invalid JSON-RPC structure"
750
946
  if isinstance(e, ValidationError):
@@ -756,12 +952,18 @@ class ASAPRequestHandler:
756
952
  error_message = "JSON-RPC 'params' must be an object"
757
953
  break
758
954
 
759
- logger.warning(
760
- "asap.request.invalid_structure",
761
- error=error_message,
762
- error_type=type(e).__name__,
763
- validation_errors=str(e.errors()) if isinstance(e, ValidationError) else str(e),
764
- )
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)
765
967
  error_response = self.build_error_response(
766
968
  error_code,
767
969
  data={
@@ -776,7 +978,6 @@ class ASAPRequestHandler:
776
978
  )
777
979
  return None, error_response
778
980
 
779
- # Check method
780
981
  if rpc_request.method != ASAP_METHOD:
781
982
  logger.warning("asap.request.unknown_method", method=rpc_request.method)
782
983
  error_response = self.build_error_response(
@@ -812,17 +1013,16 @@ class ASAPRequestHandler:
812
1013
  payload_type = "unknown"
813
1014
 
814
1015
  try:
815
- # Parse and validate JSON-RPC request
816
1016
  parse_result = await self._parse_and_validate_request(request)
817
1017
  rpc_request, parse_error = parse_result
818
1018
  if parse_error is not None:
1019
+ self._log_response_debug(parse_error)
819
1020
  return parse_error
820
- # Type narrowing: rpc_request is not None here
821
- # Use explicit check instead of assert to avoid removal in optimized builds
822
1021
  if rpc_request is None:
823
1022
  raise RuntimeError("Internal error: rpc_request is None after validation")
824
1023
 
825
- # Create request context
1024
+ self._log_request_debug(rpc_request)
1025
+
826
1026
  ctx = RequestContext(
827
1027
  request_id=rpc_request.id,
828
1028
  start_time=start_time,
@@ -830,107 +1030,115 @@ class ASAPRequestHandler:
830
1030
  rpc_request=rpc_request,
831
1031
  )
832
1032
 
833
- # Authenticate request if enabled
834
1033
  auth_result = await self._authenticate_request(request, ctx)
835
1034
  authenticated_agent_id, auth_error = auth_result
836
1035
  if auth_error is not None:
1036
+ self._log_response_debug(auth_error)
837
1037
  return auth_error
838
1038
 
839
- # Validate and extract envelope
840
1039
  envelope_result = self._validate_envelope(ctx)
841
1040
  envelope_or_none, result = envelope_result
842
1041
  if envelope_or_none is None:
843
- # result is JSONResponse when envelope is None
844
- return result # type: ignore[return-value]
1042
+ error_resp = cast(JSONResponse, result)
1043
+ self._log_response_debug(error_resp)
1044
+ return error_resp
845
1045
  envelope = envelope_or_none
846
- # result is payload_type (str) when envelope is not None
847
- payload_type = result # type: ignore[assignment]
848
-
849
- # Verify sender matches authenticated identity
850
- sender_error = self._verify_sender_matches_auth(
851
- authenticated_agent_id,
852
- envelope,
853
- ctx,
854
- payload_type,
855
- )
856
- if sender_error is not None:
857
- return sender_error
1046
+ payload_type = cast(str, result)
858
1047
 
859
- # Validate envelope timestamp to prevent replay attacks
1048
+ trace_token = extract_and_activate_envelope_trace_context(envelope)
860
1049
  try:
861
- validate_envelope_timestamp(envelope)
862
- except InvalidTimestampError as e:
863
- logger.warning(
864
- "asap.request.invalid_timestamp",
865
- envelope_id=envelope.id,
866
- error=e.message,
867
- details=e.details,
1050
+ sender_error = self._verify_sender_matches_auth(
1051
+ authenticated_agent_id,
1052
+ envelope,
1053
+ ctx,
1054
+ payload_type,
868
1055
  )
869
- duration_seconds = time.perf_counter() - ctx.start_time
870
- self.record_error_metrics(
871
- ctx.metrics, payload_type, "invalid_timestamp", duration_seconds
872
- )
873
- return self.build_error_response(
874
- INVALID_PARAMS,
875
- data={
876
- "error": "Invalid envelope timestamp",
877
- "code": e.code,
878
- "message": e.message,
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,
879
1066
  "details": e.details,
880
- },
881
- request_id=ctx.request_id,
882
- )
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
883
1116
 
884
- # Validate envelope nonce if nonce store is available
885
- try:
886
- validate_envelope_nonce(envelope, self.nonce_store)
887
- except InvalidNonceError as e:
888
- # Sanitize nonce in logs to prevent full value exposure
889
- nonce_sanitized = sanitize_nonce(e.nonce)
890
- logger.warning(
891
- "asap.request.invalid_nonce",
1117
+ logger.info(
1118
+ "asap.request.received",
892
1119
  envelope_id=envelope.id,
893
- nonce=nonce_sanitized,
894
- error=e.message,
895
- )
896
- duration_seconds = time.perf_counter() - ctx.start_time
897
- self.record_error_metrics(
898
- ctx.metrics, payload_type, "invalid_nonce", duration_seconds
899
- )
900
- return self.build_error_response(
901
- INVALID_PARAMS,
902
- data={
903
- "error": "Invalid envelope nonce",
904
- "code": e.code,
905
- "message": e.message,
906
- "details": e.details,
907
- },
908
- request_id=ctx.request_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,
909
1125
  )
910
1126
 
911
- # Log request received
912
- logger.info(
913
- "asap.request.received",
914
- envelope_id=envelope.id,
915
- trace_id=envelope.trace_id,
916
- payload_type=envelope.payload_type,
917
- sender=envelope.sender,
918
- recipient=envelope.recipient,
919
- authenticated=authenticated_agent_id is not None,
920
- )
921
-
922
- # Dispatch to handler
923
- dispatch_result = await self._dispatch_to_handler(envelope, ctx)
924
- response_or_none, result = dispatch_result
925
- if response_or_none is None:
926
- # result is JSONResponse when response is None
927
- return result # type: ignore[return-value]
928
- response_envelope = response_or_none
929
- # result is payload_type (str) when response is not None
930
- payload_type = result # type: ignore[assignment]
931
-
932
- # Build and return success response
933
- 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)
934
1142
 
935
1143
  except Exception as e:
936
1144
  # Create minimal context for error handling if we don't have rpc_request yet
@@ -947,7 +1155,9 @@ class ASAPRequestHandler:
947
1155
  metrics=temp_metrics,
948
1156
  rpc_request=temp_rpc_request,
949
1157
  )
950
- 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
951
1161
 
952
1162
 
953
1163
  def create_app(
@@ -958,6 +1168,7 @@ def create_app(
958
1168
  max_request_size: int | None = None,
959
1169
  max_threads: int | None = None,
960
1170
  require_nonce: bool = False,
1171
+ hot_reload: bool | None = None,
961
1172
  ) -> FastAPI:
962
1173
  """Create and configure a FastAPI application for ASAP protocol.
963
1174
 
@@ -977,9 +1188,14 @@ def create_app(
977
1188
  token_validator: Optional function to validate Bearer tokens.
978
1189
  Required if manifest.auth is configured. Should return agent ID
979
1190
  if token is valid, None otherwise.
980
- rate_limit: Optional rate limit string (e.g., "100/minute").
1191
+ rate_limit: Optional rate limit string (e.g., "10/second;100/minute").
981
1192
  Rate limiting is IP-based (per client IP address) to prevent DoS attacks.
982
- 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.
983
1199
  max_request_size: Optional maximum request size in bytes.
984
1200
  Defaults to ASAP_MAX_REQUEST_SIZE environment variable or 10MB.
985
1201
  max_threads: Optional maximum number of threads for sync handlers.
@@ -988,6 +1204,8 @@ def create_app(
988
1204
  require_nonce: If True, enables nonce validation for replay attack prevention.
989
1205
  When enabled, creates an InMemoryNonceStore and validates nonces in envelopes.
990
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.
991
1209
 
992
1210
  Returns:
993
1211
  Configured FastAPI application ready to run
@@ -1044,6 +1262,7 @@ def create_app(
1044
1262
  )
1045
1263
 
1046
1264
  # Use default registry if none provided
1265
+ use_default_registry = registry is None
1047
1266
  if registry is None:
1048
1267
  registry = create_default_registry()
1049
1268
 
@@ -1051,6 +1270,15 @@ def create_app(
1051
1270
  if executor is not None:
1052
1271
  registry._executor = executor
1053
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
+
1054
1282
  # Create authentication middleware if auth is configured
1055
1283
  auth_middleware: AuthenticationMiddleware | None = None
1056
1284
  if manifest.auth is not None:
@@ -1081,12 +1309,45 @@ def create_app(
1081
1309
  )
1082
1310
 
1083
1311
  # Create request handler
1084
- handler = ASAPRequestHandler(registry, manifest, auth_middleware, max_request_size, nonce_store)
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
1085
1343
 
1086
1344
  app = FastAPI(
1087
1345
  title="ASAP Protocol Server",
1088
1346
  description=f"ASAP server for {manifest.name}",
1089
1347
  version=manifest.version,
1348
+ docs_url=_docs_url,
1349
+ redoc_url=_redoc_url,
1350
+ openapi_url=_openapi_url,
1090
1351
  )
1091
1352
 
1092
1353
  # Add size limit middleware (runs before routing)
@@ -1094,7 +1355,8 @@ def create_app(
1094
1355
 
1095
1356
  # Configure rate limiting
1096
1357
  if rate_limit is None:
1097
- 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")
1098
1360
  else:
1099
1361
  rate_limit_str = rate_limit
1100
1362
 
@@ -1115,10 +1377,35 @@ def create_app(
1115
1377
  max_request_size=max_request_size,
1116
1378
  )
1117
1379
 
1118
- # Note: Request size limits should be configured at the ASGI server level (e.g., uvicorn).
1119
- # 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
1120
1383
  # (nginx, traefik) to enforce request size limits (e.g., 10MB max).
1121
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
+
1122
1409
  @app.get("/.well-known/asap/manifest.json")
1123
1410
  async def get_manifest() -> dict[str, Any]:
1124
1411
  """Return the agent's manifest for discovery.
@@ -1152,9 +1439,12 @@ def create_app(
1152
1439
  metrics = get_metrics()
1153
1440
  return PlainTextResponse(
1154
1441
  content=metrics.export_prometheus(),
1155
- media_type="text/plain; version=0.0.4; charset=utf-8",
1442
+ media_type="application/openmetrics-text; version=1.0.0; charset=utf-8",
1156
1443
  )
1157
1444
 
1445
+ # OpenTelemetry tracing (zero-config via OTEL_* env vars)
1446
+ configure_tracing(service_name=manifest.id, app=app)
1447
+
1158
1448
  @app.post("/asap")
1159
1449
  @limiter.limit(rate_limit_str) # slowapi uses app.state.limiter at runtime
1160
1450
  async def handle_asap_message(request: Request) -> JSONResponse:
@@ -1194,7 +1484,7 @@ def _create_default_manifest() -> Manifest:
1194
1484
  return Manifest(
1195
1485
  id="urn:asap:agent:default-server",
1196
1486
  name="ASAP Default Server",
1197
- version="0.3.0",
1487
+ version="1.0.0-dev",
1198
1488
  description="Default ASAP protocol server with echo capabilities",
1199
1489
  capabilities=Capability(
1200
1490
  asap_version="0.1",