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.
- asap/__init__.py +1 -1
- asap/cli.py +137 -2
- asap/examples/README.md +81 -13
- asap/examples/auth_patterns.py +212 -0
- asap/examples/error_recovery.py +248 -0
- asap/examples/long_running.py +287 -0
- asap/examples/mcp_integration.py +240 -0
- asap/examples/multi_step_workflow.py +134 -0
- asap/examples/orchestration.py +293 -0
- asap/examples/rate_limiting.py +137 -0
- asap/examples/run_demo.py +0 -2
- asap/examples/secure_handler.py +84 -0
- asap/examples/state_migration.py +240 -0
- asap/examples/streaming_response.py +108 -0
- asap/examples/websocket_concept.py +129 -0
- asap/mcp/__init__.py +43 -0
- asap/mcp/client.py +224 -0
- asap/mcp/protocol.py +179 -0
- asap/mcp/server.py +333 -0
- asap/mcp/server_runner.py +40 -0
- asap/models/base.py +0 -3
- asap/models/constants.py +3 -1
- asap/models/entities.py +21 -6
- asap/models/envelope.py +7 -0
- asap/models/ids.py +8 -4
- asap/models/parts.py +33 -3
- asap/models/validators.py +16 -0
- asap/observability/__init__.py +6 -0
- asap/observability/dashboards/README.md +24 -0
- asap/observability/dashboards/asap-detailed.json +131 -0
- asap/observability/dashboards/asap-red.json +129 -0
- asap/observability/logging.py +81 -1
- asap/observability/metrics.py +15 -1
- asap/observability/trace_parser.py +238 -0
- asap/observability/trace_ui.py +218 -0
- asap/observability/tracing.py +293 -0
- asap/state/machine.py +15 -2
- asap/state/snapshot.py +0 -9
- asap/testing/__init__.py +31 -0
- asap/testing/assertions.py +108 -0
- asap/testing/fixtures.py +113 -0
- asap/testing/mocks.py +152 -0
- asap/transport/__init__.py +28 -0
- asap/transport/cache.py +180 -0
- asap/transport/circuit_breaker.py +9 -8
- asap/transport/client.py +418 -36
- asap/transport/compression.py +389 -0
- asap/transport/handlers.py +106 -53
- asap/transport/middleware.py +58 -34
- asap/transport/server.py +429 -139
- asap/transport/validators.py +0 -4
- asap/utils/sanitization.py +0 -5
- asap_protocol-1.0.0.dist-info/METADATA +264 -0
- asap_protocol-1.0.0.dist-info/RECORD +70 -0
- asap_protocol-0.5.0.dist-info/METADATA +0 -244
- asap_protocol-0.5.0.dist-info/RECORD +0 -41
- {asap_protocol-0.5.0.dist-info → asap_protocol-1.0.0.dist-info}/WHEEL +0 -0
- {asap_protocol-0.5.0.dist-info → asap_protocol-1.0.0.dist-info}/entry_points.txt +0 -0
- {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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
301
|
-
"
|
|
302
|
-
|
|
303
|
-
|
|
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(
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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
|
-
|
|
760
|
-
"
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
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
|
-
|
|
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
|
-
|
|
844
|
-
|
|
1042
|
+
error_resp = cast(JSONResponse, result)
|
|
1043
|
+
self._log_response_debug(error_resp)
|
|
1044
|
+
return error_resp
|
|
845
1045
|
envelope = envelope_or_none
|
|
846
|
-
|
|
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
|
-
|
|
1048
|
+
trace_token = extract_and_activate_envelope_trace_context(envelope)
|
|
860
1049
|
try:
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
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
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
"
|
|
878
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
885
|
-
|
|
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
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
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
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
1119
|
-
#
|
|
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
|
|
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.
|
|
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",
|