asap-protocol 0.3.0__py3-none-any.whl → 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- asap/__init__.py +1 -1
- asap/cli.py +137 -2
- asap/errors.py +167 -0
- asap/examples/README.md +81 -10
- asap/examples/auth_patterns.py +212 -0
- asap/examples/error_recovery.py +248 -0
- asap/examples/long_running.py +287 -0
- asap/examples/mcp_integration.py +240 -0
- asap/examples/multi_step_workflow.py +134 -0
- asap/examples/orchestration.py +293 -0
- asap/examples/rate_limiting.py +137 -0
- asap/examples/run_demo.py +9 -4
- asap/examples/secure_handler.py +84 -0
- asap/examples/state_migration.py +240 -0
- asap/examples/streaming_response.py +108 -0
- asap/examples/websocket_concept.py +129 -0
- asap/mcp/__init__.py +43 -0
- asap/mcp/client.py +224 -0
- asap/mcp/protocol.py +179 -0
- asap/mcp/server.py +333 -0
- asap/mcp/server_runner.py +40 -0
- asap/models/__init__.py +4 -0
- asap/models/base.py +0 -3
- asap/models/constants.py +76 -1
- asap/models/entities.py +58 -7
- asap/models/envelope.py +14 -1
- asap/models/ids.py +8 -4
- asap/models/parts.py +33 -3
- asap/models/validators.py +16 -0
- asap/observability/__init__.py +6 -0
- asap/observability/dashboards/README.md +24 -0
- asap/observability/dashboards/asap-detailed.json +131 -0
- asap/observability/dashboards/asap-red.json +129 -0
- asap/observability/logging.py +81 -1
- asap/observability/metrics.py +15 -1
- asap/observability/trace_parser.py +238 -0
- asap/observability/trace_ui.py +218 -0
- asap/observability/tracing.py +293 -0
- asap/state/machine.py +15 -2
- asap/state/snapshot.py +0 -9
- asap/testing/__init__.py +31 -0
- asap/testing/assertions.py +108 -0
- asap/testing/fixtures.py +113 -0
- asap/testing/mocks.py +152 -0
- asap/transport/__init__.py +31 -0
- asap/transport/cache.py +180 -0
- asap/transport/circuit_breaker.py +194 -0
- asap/transport/client.py +989 -72
- asap/transport/compression.py +389 -0
- asap/transport/handlers.py +106 -53
- asap/transport/middleware.py +64 -39
- asap/transport/server.py +461 -94
- asap/transport/validators.py +320 -0
- asap/utils/__init__.py +7 -0
- asap/utils/sanitization.py +134 -0
- asap_protocol-1.0.0.dist-info/METADATA +264 -0
- asap_protocol-1.0.0.dist-info/RECORD +70 -0
- asap_protocol-0.3.0.dist-info/METADATA +0 -227
- asap_protocol-0.3.0.dist-info/RECORD +0 -37
- {asap_protocol-0.3.0.dist-info → asap_protocol-1.0.0.dist-info}/WHEEL +0 -0
- {asap_protocol-0.3.0.dist-info → asap_protocol-1.0.0.dist-info}/entry_points.txt +0 -0
- {asap_protocol-0.3.0.dist-info → asap_protocol-1.0.0.dist-info}/licenses/LICENSE +0 -0
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
291
|
-
"
|
|
292
|
-
|
|
293
|
-
|
|
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(
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
-
|
|
750
|
-
"
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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
|
-
|
|
811
|
-
|
|
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
|
-
|
|
832
|
-
|
|
1042
|
+
error_resp = cast(JSONResponse, result)
|
|
1043
|
+
self._log_response_debug(error_resp)
|
|
1044
|
+
return error_resp
|
|
833
1045
|
envelope = envelope_or_none
|
|
834
|
-
|
|
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
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
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
|
-
|
|
869
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
1042
|
-
#
|
|
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
|
|
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.
|
|
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",
|