asap-protocol 0.1.0__py3-none-any.whl → 0.5.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/errors.py +209 -0
- asap/examples/README.md +3 -0
- asap/examples/coordinator.py +1 -1
- asap/examples/echo_agent.py +1 -1
- asap/examples/run_demo.py +9 -2
- asap/models/__init__.py +4 -0
- asap/models/constants.py +74 -0
- asap/models/entities.py +38 -2
- asap/models/envelope.py +7 -1
- asap/observability/metrics.py +1 -0
- asap/transport/__init__.py +3 -0
- asap/transport/circuit_breaker.py +193 -0
- asap/transport/client.py +588 -53
- asap/transport/executors.py +156 -0
- asap/transport/handlers.py +17 -5
- asap/transport/middleware.py +304 -8
- asap/transport/server.py +729 -258
- asap/transport/validators.py +324 -0
- asap/utils/__init__.py +7 -0
- asap/utils/sanitization.py +139 -0
- {asap_protocol-0.1.0.dist-info → asap_protocol-0.5.0.dist-info}/METADATA +66 -73
- asap_protocol-0.5.0.dist-info/RECORD +41 -0
- asap_protocol-0.1.0.dist-info/RECORD +0 -36
- {asap_protocol-0.1.0.dist-info → asap_protocol-0.5.0.dist-info}/WHEEL +0 -0
- {asap_protocol-0.1.0.dist-info → asap_protocol-0.5.0.dist-info}/entry_points.txt +0 -0
- {asap_protocol-0.1.0.dist-info → asap_protocol-0.5.0.dist-info}/licenses/LICENSE +0 -0
asap/transport/server.py
CHANGED
|
@@ -40,18 +40,33 @@ Example:
|
|
|
40
40
|
>>> # Run with: uvicorn asap.transport.server:app --host 0.0.0.0 --port 8000
|
|
41
41
|
"""
|
|
42
42
|
|
|
43
|
+
import json
|
|
44
|
+
import os
|
|
43
45
|
import time
|
|
44
|
-
from
|
|
46
|
+
from dataclasses import dataclass
|
|
47
|
+
from typing import Any, Callable, TypeVar
|
|
45
48
|
|
|
46
49
|
from fastapi import FastAPI, HTTPException, Request
|
|
47
50
|
from fastapi.responses import JSONResponse, PlainTextResponse
|
|
48
51
|
from pydantic import ValidationError
|
|
52
|
+
from slowapi.errors import RateLimitExceeded
|
|
49
53
|
|
|
54
|
+
from asap.errors import InvalidNonceError, InvalidTimestampError, ThreadPoolExhaustedError
|
|
55
|
+
from asap.models.constants import MAX_REQUEST_SIZE
|
|
50
56
|
from asap.models.entities import Capability, Endpoint, Manifest, Skill
|
|
51
57
|
from asap.models.envelope import Envelope
|
|
52
58
|
from asap.observability import get_logger, get_metrics
|
|
53
|
-
from asap.
|
|
59
|
+
from asap.utils.sanitization import sanitize_nonce
|
|
60
|
+
from asap.transport.middleware import (
|
|
61
|
+
AuthenticationMiddleware,
|
|
62
|
+
BearerTokenValidator,
|
|
63
|
+
SizeLimitMiddleware,
|
|
64
|
+
create_limiter,
|
|
65
|
+
limiter,
|
|
66
|
+
rate_limit_handler,
|
|
67
|
+
)
|
|
54
68
|
from asap.observability.metrics import MetricsCollector
|
|
69
|
+
from asap.transport.executors import BoundedExecutor
|
|
55
70
|
from asap.transport.handlers import (
|
|
56
71
|
HandlerNotFoundError,
|
|
57
72
|
HandlerRegistry,
|
|
@@ -69,10 +84,40 @@ from asap.transport.jsonrpc import (
|
|
|
69
84
|
JsonRpcRequest,
|
|
70
85
|
JsonRpcResponse,
|
|
71
86
|
)
|
|
87
|
+
from asap.transport.validators import (
|
|
88
|
+
InMemoryNonceStore,
|
|
89
|
+
NonceStore,
|
|
90
|
+
validate_envelope_nonce,
|
|
91
|
+
validate_envelope_timestamp,
|
|
92
|
+
)
|
|
72
93
|
|
|
73
94
|
# Module logger
|
|
74
95
|
logger = get_logger(__name__)
|
|
75
96
|
|
|
97
|
+
# Type variable for handler result pattern
|
|
98
|
+
T = TypeVar("T")
|
|
99
|
+
HandlerResult = tuple[T | None, JSONResponse | None]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class RequestContext:
|
|
104
|
+
"""Request-scoped context for handler processing.
|
|
105
|
+
|
|
106
|
+
Groups request-scoped data that is passed to multiple helper methods
|
|
107
|
+
to reduce parameter noise and improve code readability.
|
|
108
|
+
|
|
109
|
+
Attributes:
|
|
110
|
+
request_id: JSON-RPC request ID (str, int, or None)
|
|
111
|
+
start_time: Request start time for duration calculation
|
|
112
|
+
metrics: Metrics collector for observability
|
|
113
|
+
rpc_request: Validated JSON-RPC request object
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
request_id: str | int | None
|
|
117
|
+
start_time: float
|
|
118
|
+
metrics: MetricsCollector
|
|
119
|
+
rpc_request: JsonRpcRequest
|
|
120
|
+
|
|
76
121
|
|
|
77
122
|
class ASAPRequestHandler:
|
|
78
123
|
"""Handler for processing ASAP protocol requests.
|
|
@@ -103,6 +148,8 @@ class ASAPRequestHandler:
|
|
|
103
148
|
registry: HandlerRegistry,
|
|
104
149
|
manifest: Manifest,
|
|
105
150
|
auth_middleware: AuthenticationMiddleware | None = None,
|
|
151
|
+
max_request_size: int = MAX_REQUEST_SIZE,
|
|
152
|
+
nonce_store: NonceStore | None = None,
|
|
106
153
|
) -> None:
|
|
107
154
|
"""Initialize the request handler.
|
|
108
155
|
|
|
@@ -110,10 +157,31 @@ class ASAPRequestHandler:
|
|
|
110
157
|
registry: Handler registry for dispatching payloads
|
|
111
158
|
manifest: Agent manifest describing capabilities
|
|
112
159
|
auth_middleware: Optional authentication middleware for request validation
|
|
160
|
+
max_request_size: Maximum allowed request size in bytes
|
|
161
|
+
nonce_store: Optional nonce store for replay attack prevention
|
|
113
162
|
"""
|
|
114
163
|
self.registry = registry
|
|
115
164
|
self.manifest = manifest
|
|
116
165
|
self.auth_middleware = auth_middleware
|
|
166
|
+
self.max_request_size = max_request_size
|
|
167
|
+
self.nonce_store = nonce_store
|
|
168
|
+
|
|
169
|
+
def _normalize_payload_type_for_metrics(self, payload_type: str) -> str:
|
|
170
|
+
"""Normalize payload type for metrics to prevent cardinality explosion.
|
|
171
|
+
|
|
172
|
+
Only registered payload types are used as metric labels. Unknown
|
|
173
|
+
payload types are normalized to "other" to prevent DoS attacks
|
|
174
|
+
through metric cardinality explosion.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
payload_type: The payload type to normalize
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
The payload type if registered, or "other" if unknown
|
|
181
|
+
"""
|
|
182
|
+
if self.registry.has_handler(payload_type):
|
|
183
|
+
return payload_type
|
|
184
|
+
return "other"
|
|
117
185
|
|
|
118
186
|
def build_error_response(
|
|
119
187
|
self,
|
|
@@ -152,22 +220,475 @@ class ASAPRequestHandler:
|
|
|
152
220
|
error_type: Type of error that occurred
|
|
153
221
|
duration_seconds: Request duration in seconds
|
|
154
222
|
"""
|
|
223
|
+
# Normalize payload_type to prevent cardinality explosion
|
|
224
|
+
normalized_payload_type = self._normalize_payload_type_for_metrics(payload_type)
|
|
155
225
|
metrics.increment_counter(
|
|
156
226
|
"asap_requests_total",
|
|
157
|
-
{"payload_type":
|
|
227
|
+
{"payload_type": normalized_payload_type, "status": "error"},
|
|
158
228
|
)
|
|
159
229
|
metrics.increment_counter(
|
|
160
230
|
"asap_requests_error_total",
|
|
161
|
-
{"payload_type":
|
|
231
|
+
{"payload_type": normalized_payload_type, "error_type": error_type},
|
|
162
232
|
)
|
|
163
233
|
metrics.observe_histogram(
|
|
164
234
|
"asap_request_duration_seconds",
|
|
165
235
|
duration_seconds,
|
|
166
|
-
{"payload_type":
|
|
236
|
+
{"payload_type": normalized_payload_type, "status": "error"},
|
|
167
237
|
)
|
|
168
238
|
|
|
239
|
+
def _validate_envelope(
|
|
240
|
+
self,
|
|
241
|
+
ctx: RequestContext,
|
|
242
|
+
) -> tuple[Envelope | None, JSONResponse | str]:
|
|
243
|
+
"""Validate and extract envelope from JSON-RPC params.
|
|
244
|
+
|
|
245
|
+
Validates that params is a dict, extracts the envelope field,
|
|
246
|
+
and validates the envelope structure.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
ctx: Request context with rpc_request, start_time, and metrics
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
Tuple of (Envelope, payload_type) if valid, or (None, error_response) if invalid
|
|
253
|
+
"""
|
|
254
|
+
rpc_request = ctx.rpc_request
|
|
255
|
+
# Validate params is a dict before accessing
|
|
256
|
+
if not isinstance(rpc_request.params, dict):
|
|
257
|
+
logger.warning(
|
|
258
|
+
"asap.request.invalid_params_type",
|
|
259
|
+
params_type=type(rpc_request.params).__name__,
|
|
260
|
+
)
|
|
261
|
+
error_response = self.build_error_response(
|
|
262
|
+
INVALID_PARAMS,
|
|
263
|
+
data={
|
|
264
|
+
"error": "JSON-RPC 'params' must be an object",
|
|
265
|
+
"received_type": type(rpc_request.params).__name__,
|
|
266
|
+
},
|
|
267
|
+
request_id=ctx.request_id,
|
|
268
|
+
)
|
|
269
|
+
self.record_error_metrics(
|
|
270
|
+
ctx.metrics,
|
|
271
|
+
"unknown",
|
|
272
|
+
"invalid_params",
|
|
273
|
+
time.perf_counter() - ctx.start_time,
|
|
274
|
+
)
|
|
275
|
+
return None, error_response
|
|
276
|
+
|
|
277
|
+
# Extract envelope from params
|
|
278
|
+
envelope_data = rpc_request.params.get("envelope")
|
|
279
|
+
if envelope_data is None:
|
|
280
|
+
logger.warning("asap.request.missing_envelope")
|
|
281
|
+
error_response = self.build_error_response(
|
|
282
|
+
INVALID_PARAMS,
|
|
283
|
+
data={"error": "Missing 'envelope' in params"},
|
|
284
|
+
request_id=ctx.request_id,
|
|
285
|
+
)
|
|
286
|
+
self.record_error_metrics(
|
|
287
|
+
ctx.metrics,
|
|
288
|
+
"unknown",
|
|
289
|
+
"missing_envelope",
|
|
290
|
+
time.perf_counter() - ctx.start_time,
|
|
291
|
+
)
|
|
292
|
+
return None, error_response
|
|
293
|
+
|
|
294
|
+
# Validate envelope structure
|
|
295
|
+
try:
|
|
296
|
+
envelope = Envelope(**envelope_data)
|
|
297
|
+
payload_type = envelope.payload_type
|
|
298
|
+
return envelope, payload_type
|
|
299
|
+
except ValidationError as e:
|
|
300
|
+
logger.warning(
|
|
301
|
+
"asap.request.invalid_envelope",
|
|
302
|
+
error="Invalid envelope structure",
|
|
303
|
+
validation_errors=str(e.errors()),
|
|
304
|
+
)
|
|
305
|
+
duration_seconds = time.perf_counter() - ctx.start_time
|
|
306
|
+
self.record_error_metrics(ctx.metrics, "unknown", "invalid_envelope", duration_seconds)
|
|
307
|
+
error_response = self.build_error_response(
|
|
308
|
+
INVALID_PARAMS,
|
|
309
|
+
data={
|
|
310
|
+
"error": "Invalid envelope structure",
|
|
311
|
+
"validation_errors": e.errors(),
|
|
312
|
+
},
|
|
313
|
+
request_id=ctx.request_id,
|
|
314
|
+
)
|
|
315
|
+
return None, error_response
|
|
316
|
+
|
|
317
|
+
async def _dispatch_to_handler(
|
|
318
|
+
self,
|
|
319
|
+
envelope: Envelope,
|
|
320
|
+
ctx: RequestContext,
|
|
321
|
+
) -> tuple[Envelope | None, JSONResponse | str]:
|
|
322
|
+
"""Dispatch envelope to registered handler.
|
|
323
|
+
|
|
324
|
+
Looks up and executes the handler for the envelope's payload type.
|
|
325
|
+
Handles HandlerNotFoundError and converts it to JSON-RPC error response.
|
|
326
|
+
|
|
327
|
+
Args:
|
|
328
|
+
envelope: Validated ASAP envelope
|
|
329
|
+
ctx: Request context with rpc_request, start_time, and metrics
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
Tuple of (response_envelope, payload_type) if successful,
|
|
333
|
+
or (None, error_response) if handler not found
|
|
334
|
+
"""
|
|
335
|
+
payload_type = envelope.payload_type
|
|
336
|
+
try:
|
|
337
|
+
response_envelope = await self.registry.dispatch_async(envelope, self.manifest)
|
|
338
|
+
return response_envelope, payload_type
|
|
339
|
+
except ThreadPoolExhaustedError as e:
|
|
340
|
+
# Thread pool exhausted - service temporarily unavailable
|
|
341
|
+
logger.warning(
|
|
342
|
+
"asap.request.thread_pool_exhausted",
|
|
343
|
+
payload_type=payload_type,
|
|
344
|
+
envelope_id=envelope.id,
|
|
345
|
+
max_threads=e.max_threads,
|
|
346
|
+
active_threads=e.active_threads,
|
|
347
|
+
)
|
|
348
|
+
# Record error metric
|
|
349
|
+
duration_seconds = time.perf_counter() - ctx.start_time
|
|
350
|
+
self.record_error_metrics(
|
|
351
|
+
ctx.metrics, payload_type, "thread_pool_exhausted", duration_seconds
|
|
352
|
+
)
|
|
353
|
+
# Return HTTP 503 Service Unavailable (not JSON-RPC error)
|
|
354
|
+
error_response = JSONResponse(
|
|
355
|
+
status_code=503,
|
|
356
|
+
content={
|
|
357
|
+
"error": "Service Temporarily Unavailable",
|
|
358
|
+
"code": e.code,
|
|
359
|
+
"message": e.message,
|
|
360
|
+
"details": e.details,
|
|
361
|
+
},
|
|
362
|
+
)
|
|
363
|
+
return None, error_response
|
|
364
|
+
except HandlerNotFoundError as e:
|
|
365
|
+
# No handler registered for this payload type
|
|
366
|
+
logger.warning(
|
|
367
|
+
"asap.request.handler_not_found",
|
|
368
|
+
payload_type=e.payload_type,
|
|
369
|
+
envelope_id=envelope.id,
|
|
370
|
+
)
|
|
371
|
+
# Record error metric
|
|
372
|
+
duration_seconds = time.perf_counter() - ctx.start_time
|
|
373
|
+
self.record_error_metrics(
|
|
374
|
+
ctx.metrics, payload_type, "handler_not_found", duration_seconds
|
|
375
|
+
)
|
|
376
|
+
handler_error = JsonRpcErrorResponse(
|
|
377
|
+
error=JsonRpcError.from_code(
|
|
378
|
+
METHOD_NOT_FOUND,
|
|
379
|
+
data={
|
|
380
|
+
"payload_type": e.payload_type,
|
|
381
|
+
"error": str(e),
|
|
382
|
+
},
|
|
383
|
+
),
|
|
384
|
+
id=ctx.request_id,
|
|
385
|
+
)
|
|
386
|
+
error_response = JSONResponse(
|
|
387
|
+
status_code=200,
|
|
388
|
+
content=handler_error.model_dump(),
|
|
389
|
+
)
|
|
390
|
+
return None, error_response
|
|
391
|
+
|
|
392
|
+
async def _authenticate_request(
|
|
393
|
+
self,
|
|
394
|
+
request: Request,
|
|
395
|
+
ctx: RequestContext,
|
|
396
|
+
) -> HandlerResult[str]:
|
|
397
|
+
"""Authenticate the request if authentication is enabled.
|
|
398
|
+
|
|
399
|
+
Args:
|
|
400
|
+
request: FastAPI request object
|
|
401
|
+
ctx: Request context with rpc_request, start_time, and metrics
|
|
402
|
+
|
|
403
|
+
Returns:
|
|
404
|
+
Tuple of (authenticated_agent_id, None) if successful or auth disabled,
|
|
405
|
+
or (None, error_response) if authentication failed
|
|
406
|
+
"""
|
|
407
|
+
if self.auth_middleware is None:
|
|
408
|
+
return None, None
|
|
409
|
+
|
|
410
|
+
try:
|
|
411
|
+
authenticated_agent_id = await self.auth_middleware.verify_authentication(request)
|
|
412
|
+
return authenticated_agent_id, None
|
|
413
|
+
except HTTPException as e:
|
|
414
|
+
# Authentication failed - return JSON-RPC error
|
|
415
|
+
logger.warning(
|
|
416
|
+
"asap.request.auth_failed",
|
|
417
|
+
status_code=e.status_code,
|
|
418
|
+
detail=e.detail,
|
|
419
|
+
)
|
|
420
|
+
# Map HTTP status to JSON-RPC error code
|
|
421
|
+
error_code = INVALID_REQUEST if e.status_code == 401 else INVALID_PARAMS
|
|
422
|
+
error_response = self.build_error_response(
|
|
423
|
+
error_code,
|
|
424
|
+
data={"error": str(e.detail), "status_code": e.status_code},
|
|
425
|
+
request_id=ctx.request_id,
|
|
426
|
+
)
|
|
427
|
+
self.record_error_metrics(
|
|
428
|
+
ctx.metrics,
|
|
429
|
+
"unknown",
|
|
430
|
+
"auth_failed",
|
|
431
|
+
time.perf_counter() - ctx.start_time,
|
|
432
|
+
)
|
|
433
|
+
return None, error_response
|
|
434
|
+
|
|
435
|
+
def _verify_sender_matches_auth(
|
|
436
|
+
self,
|
|
437
|
+
authenticated_agent_id: str | None,
|
|
438
|
+
envelope: Envelope,
|
|
439
|
+
ctx: RequestContext,
|
|
440
|
+
payload_type: str,
|
|
441
|
+
) -> JSONResponse | None:
|
|
442
|
+
"""Verify that envelope sender matches authenticated identity.
|
|
443
|
+
|
|
444
|
+
Args:
|
|
445
|
+
authenticated_agent_id: Authenticated agent ID from auth middleware
|
|
446
|
+
envelope: Validated ASAP envelope
|
|
447
|
+
ctx: Request context with rpc_request, start_time, and metrics
|
|
448
|
+
payload_type: Payload type for metrics
|
|
449
|
+
|
|
450
|
+
Returns:
|
|
451
|
+
None if verification passes, or error_response if sender mismatch
|
|
452
|
+
"""
|
|
453
|
+
if self.auth_middleware is None:
|
|
454
|
+
return None
|
|
455
|
+
|
|
456
|
+
try:
|
|
457
|
+
self.auth_middleware.verify_sender_matches_auth(authenticated_agent_id, envelope.sender)
|
|
458
|
+
return None
|
|
459
|
+
except HTTPException as e:
|
|
460
|
+
# Sender mismatch - return JSON-RPC error
|
|
461
|
+
logger.warning(
|
|
462
|
+
"asap.request.sender_mismatch",
|
|
463
|
+
authenticated_agent=authenticated_agent_id,
|
|
464
|
+
envelope_sender=envelope.sender,
|
|
465
|
+
)
|
|
466
|
+
error_response = self.build_error_response(
|
|
467
|
+
INVALID_PARAMS,
|
|
468
|
+
data={"error": str(e.detail), "status_code": e.status_code},
|
|
469
|
+
request_id=ctx.request_id,
|
|
470
|
+
)
|
|
471
|
+
duration_seconds = time.perf_counter() - ctx.start_time
|
|
472
|
+
self.record_error_metrics(
|
|
473
|
+
ctx.metrics, payload_type, "sender_mismatch", duration_seconds
|
|
474
|
+
)
|
|
475
|
+
return error_response
|
|
476
|
+
|
|
477
|
+
def _build_success_response(
|
|
478
|
+
self,
|
|
479
|
+
response_envelope: Envelope,
|
|
480
|
+
ctx: RequestContext,
|
|
481
|
+
payload_type: str,
|
|
482
|
+
) -> JSONResponse:
|
|
483
|
+
"""Build success response with metrics and logging.
|
|
484
|
+
|
|
485
|
+
Args:
|
|
486
|
+
response_envelope: Response envelope from handler
|
|
487
|
+
ctx: Request context with rpc_request, start_time, and metrics
|
|
488
|
+
payload_type: Payload type for metrics
|
|
489
|
+
|
|
490
|
+
Returns:
|
|
491
|
+
JSON-RPC success response
|
|
492
|
+
"""
|
|
493
|
+
duration_seconds = time.perf_counter() - ctx.start_time
|
|
494
|
+
duration_ms = duration_seconds * 1000
|
|
495
|
+
|
|
496
|
+
# Normalize payload_type to prevent cardinality explosion
|
|
497
|
+
normalized_payload_type = self._normalize_payload_type_for_metrics(payload_type)
|
|
498
|
+
|
|
499
|
+
# Record success metrics
|
|
500
|
+
ctx.metrics.increment_counter(
|
|
501
|
+
"asap_requests_total",
|
|
502
|
+
{"payload_type": normalized_payload_type, "status": "success"},
|
|
503
|
+
)
|
|
504
|
+
ctx.metrics.increment_counter(
|
|
505
|
+
"asap_requests_success_total",
|
|
506
|
+
{"payload_type": normalized_payload_type},
|
|
507
|
+
)
|
|
508
|
+
ctx.metrics.observe_histogram(
|
|
509
|
+
"asap_request_duration_seconds",
|
|
510
|
+
duration_seconds,
|
|
511
|
+
{"payload_type": normalized_payload_type, "status": "success"},
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
# Log successful processing
|
|
515
|
+
logger.info(
|
|
516
|
+
"asap.request.processed",
|
|
517
|
+
envelope_id=response_envelope.id,
|
|
518
|
+
response_id=response_envelope.id,
|
|
519
|
+
trace_id=response_envelope.trace_id,
|
|
520
|
+
payload_type=payload_type,
|
|
521
|
+
duration_ms=round(duration_ms, 2),
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
# Wrap response in JSON-RPC
|
|
525
|
+
# JsonRpcResponse requires id to be str | int, not None
|
|
526
|
+
response_id: str | int = ctx.request_id if ctx.request_id is not None else ""
|
|
527
|
+
rpc_response = JsonRpcResponse(
|
|
528
|
+
result={"envelope": response_envelope.model_dump(mode="json")},
|
|
529
|
+
id=response_id,
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
return JSONResponse(
|
|
533
|
+
status_code=200,
|
|
534
|
+
content=rpc_response.model_dump(),
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
def _handle_internal_error(
|
|
538
|
+
self,
|
|
539
|
+
error: Exception,
|
|
540
|
+
ctx: RequestContext,
|
|
541
|
+
payload_type: str,
|
|
542
|
+
) -> JSONResponse:
|
|
543
|
+
"""Handle internal server errors with metrics and logging.
|
|
544
|
+
|
|
545
|
+
Args:
|
|
546
|
+
error: The exception that occurred
|
|
547
|
+
ctx: Request context with rpc_request, start_time, and metrics
|
|
548
|
+
payload_type: Payload type for metrics
|
|
549
|
+
|
|
550
|
+
Returns:
|
|
551
|
+
JSON-RPC internal error response
|
|
552
|
+
"""
|
|
553
|
+
duration_seconds = time.perf_counter() - ctx.start_time
|
|
554
|
+
duration_ms = duration_seconds * 1000
|
|
555
|
+
|
|
556
|
+
# Record error metrics (normalized to prevent cardinality explosion)
|
|
557
|
+
self.record_error_metrics(ctx.metrics, payload_type, "internal_error", duration_seconds)
|
|
558
|
+
|
|
559
|
+
# Log error
|
|
560
|
+
logger.exception(
|
|
561
|
+
"asap.request.error",
|
|
562
|
+
error=str(error),
|
|
563
|
+
error_type=type(error).__name__,
|
|
564
|
+
duration_ms=round(duration_ms, 2),
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
# Internal server error
|
|
568
|
+
internal_error = JsonRpcErrorResponse(
|
|
569
|
+
error=JsonRpcError.from_code(
|
|
570
|
+
INTERNAL_ERROR,
|
|
571
|
+
data={"error": str(error), "type": type(error).__name__},
|
|
572
|
+
),
|
|
573
|
+
id=ctx.request_id,
|
|
574
|
+
)
|
|
575
|
+
return JSONResponse(
|
|
576
|
+
status_code=200,
|
|
577
|
+
content=internal_error.model_dump(),
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
async def _parse_and_validate_request(
|
|
581
|
+
self,
|
|
582
|
+
request: Request,
|
|
583
|
+
) -> HandlerResult[JsonRpcRequest]:
|
|
584
|
+
"""Parse JSON body and validate JSON-RPC request structure.
|
|
585
|
+
|
|
586
|
+
Args:
|
|
587
|
+
request: FastAPI request object
|
|
588
|
+
|
|
589
|
+
Returns:
|
|
590
|
+
Tuple of (JsonRpcRequest, None) if valid, or (None, error_response) if invalid
|
|
591
|
+
"""
|
|
592
|
+
# Parse JSON body
|
|
593
|
+
try:
|
|
594
|
+
body = await self.parse_json_body(request)
|
|
595
|
+
except HTTPException as e:
|
|
596
|
+
# HTTPException (e.g., 413 Payload Too Large) should be returned directly
|
|
597
|
+
# Don't convert to JSON-RPC error response
|
|
598
|
+
from fastapi.responses import JSONResponse
|
|
599
|
+
|
|
600
|
+
return None, JSONResponse(
|
|
601
|
+
status_code=e.status_code,
|
|
602
|
+
content={"detail": e.detail},
|
|
603
|
+
headers=e.headers if hasattr(e, "headers") else None,
|
|
604
|
+
)
|
|
605
|
+
except ValueError as e:
|
|
606
|
+
# Invalid JSON - return parse error
|
|
607
|
+
error_response = self.build_error_response(
|
|
608
|
+
PARSE_ERROR,
|
|
609
|
+
data={"error": str(e)},
|
|
610
|
+
request_id=None,
|
|
611
|
+
)
|
|
612
|
+
# Create temporary context for metrics (before we have rpc_request)
|
|
613
|
+
temp_metrics = get_metrics()
|
|
614
|
+
self.record_error_metrics(temp_metrics, "unknown", "parse_error", 0.0)
|
|
615
|
+
return None, error_response
|
|
616
|
+
|
|
617
|
+
# Validate body is a dict (JSON-RPC requires object at root)
|
|
618
|
+
if not isinstance(body, dict):
|
|
619
|
+
error_response = self.build_error_response(
|
|
620
|
+
INVALID_REQUEST,
|
|
621
|
+
data={
|
|
622
|
+
"error": "JSON-RPC request must be an object",
|
|
623
|
+
"received_type": type(body).__name__,
|
|
624
|
+
},
|
|
625
|
+
request_id=None,
|
|
626
|
+
)
|
|
627
|
+
temp_metrics = get_metrics()
|
|
628
|
+
self.record_error_metrics(temp_metrics, "unknown", "invalid_request", 0.0)
|
|
629
|
+
return None, error_response
|
|
630
|
+
|
|
631
|
+
# Validate JSON-RPC request structure and method
|
|
632
|
+
rpc_request, validation_error = self.validate_jsonrpc_request(body)
|
|
633
|
+
if validation_error is not None:
|
|
634
|
+
temp_metrics = get_metrics()
|
|
635
|
+
self.record_error_metrics(temp_metrics, "unknown", "invalid_request", 0.0)
|
|
636
|
+
return None, validation_error
|
|
637
|
+
|
|
638
|
+
# Type narrowing: rpc_request is not None here
|
|
639
|
+
if rpc_request is None:
|
|
640
|
+
# This should not happen if validate_jsonrpc_request is correct
|
|
641
|
+
# but guard against it for robustness
|
|
642
|
+
error_response = self.build_error_response(
|
|
643
|
+
INTERNAL_ERROR,
|
|
644
|
+
data={"error": "Internal validation error"},
|
|
645
|
+
request_id=None,
|
|
646
|
+
)
|
|
647
|
+
temp_metrics = get_metrics()
|
|
648
|
+
self.record_error_metrics(
|
|
649
|
+
temp_metrics, "unknown", "internal_error", time.perf_counter()
|
|
650
|
+
)
|
|
651
|
+
return None, error_response
|
|
652
|
+
|
|
653
|
+
return rpc_request, None
|
|
654
|
+
|
|
655
|
+
def _validate_request_size(self, request: Request, max_size: int) -> None:
|
|
656
|
+
"""Validate that request size does not exceed maximum.
|
|
657
|
+
|
|
658
|
+
Checks Content-Length header first, then validates actual body size
|
|
659
|
+
if available. Raises HTTPException(413) if request is too large.
|
|
660
|
+
|
|
661
|
+
Args:
|
|
662
|
+
request: FastAPI request object
|
|
663
|
+
max_size: Maximum allowed request size in bytes
|
|
664
|
+
|
|
665
|
+
Raises:
|
|
666
|
+
HTTPException: If request size exceeds maximum (413 Payload Too Large)
|
|
667
|
+
"""
|
|
668
|
+
# Check Content-Length header first
|
|
669
|
+
content_length = request.headers.get("content-length")
|
|
670
|
+
if content_length:
|
|
671
|
+
try:
|
|
672
|
+
size = int(content_length)
|
|
673
|
+
if size > max_size:
|
|
674
|
+
logger.warning(
|
|
675
|
+
"asap.request.size_exceeded",
|
|
676
|
+
content_length=size,
|
|
677
|
+
max_size=max_size,
|
|
678
|
+
)
|
|
679
|
+
raise HTTPException(
|
|
680
|
+
status_code=413,
|
|
681
|
+
detail=f"Request size ({size} bytes) exceeds maximum ({max_size} bytes)",
|
|
682
|
+
)
|
|
683
|
+
except ValueError:
|
|
684
|
+
# Invalid Content-Length header, will check body size instead
|
|
685
|
+
pass
|
|
686
|
+
|
|
169
687
|
async def parse_json_body(self, request: Request) -> dict[str, Any]:
|
|
170
|
-
"""Parse JSON body from request.
|
|
688
|
+
"""Parse JSON body from request with size validation.
|
|
689
|
+
|
|
690
|
+
Validates request size before parsing to prevent DoS attacks.
|
|
691
|
+
Checks both Content-Length header and actual body size.
|
|
171
692
|
|
|
172
693
|
Args:
|
|
173
694
|
request: FastAPI request object
|
|
@@ -176,14 +697,37 @@ class ASAPRequestHandler:
|
|
|
176
697
|
Parsed JSON body
|
|
177
698
|
|
|
178
699
|
Raises:
|
|
700
|
+
HTTPException: If request size exceeds maximum (413)
|
|
179
701
|
ValueError: If JSON is invalid
|
|
180
702
|
"""
|
|
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
|
|
181
706
|
try:
|
|
182
|
-
|
|
707
|
+
body_bytes = bytearray()
|
|
708
|
+
async for chunk in request.stream():
|
|
709
|
+
body_bytes.extend(chunk)
|
|
710
|
+
# Validate size after each chunk to abort early if limit exceeded
|
|
711
|
+
if len(body_bytes) > self.max_request_size:
|
|
712
|
+
logger.warning(
|
|
713
|
+
"asap.request.size_exceeded",
|
|
714
|
+
actual_size=len(body_bytes),
|
|
715
|
+
max_size=self.max_request_size,
|
|
716
|
+
)
|
|
717
|
+
raise HTTPException(
|
|
718
|
+
status_code=413,
|
|
719
|
+
detail=f"Request size ({len(body_bytes)} bytes) exceeds maximum ({self.max_request_size} bytes)",
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
# Parse JSON from bytes
|
|
723
|
+
body: dict[str, Any] = json.loads(body_bytes.decode("utf-8"))
|
|
183
724
|
return body
|
|
184
|
-
except
|
|
725
|
+
except UnicodeDecodeError as e:
|
|
726
|
+
logger.warning("asap.request.invalid_encoding", error=str(e))
|
|
727
|
+
raise ValueError(f"Invalid UTF-8 encoding: {e}") from e
|
|
728
|
+
except json.JSONDecodeError as e:
|
|
185
729
|
logger.warning("asap.request.invalid_json", error=str(e))
|
|
186
|
-
raise
|
|
730
|
+
raise ValueError(f"Invalid JSON: {e}") from e
|
|
187
731
|
|
|
188
732
|
def validate_jsonrpc_request(
|
|
189
733
|
self, body: dict[str, Any]
|
|
@@ -268,158 +812,102 @@ class ASAPRequestHandler:
|
|
|
268
812
|
payload_type = "unknown"
|
|
269
813
|
|
|
270
814
|
try:
|
|
271
|
-
# Parse JSON
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
)
|
|
281
|
-
self.record_error_metrics(metrics, "unknown", "parse_error", 0.0)
|
|
282
|
-
return error_response
|
|
815
|
+
# Parse and validate JSON-RPC request
|
|
816
|
+
parse_result = await self._parse_and_validate_request(request)
|
|
817
|
+
rpc_request, parse_error = parse_result
|
|
818
|
+
if parse_error is not None:
|
|
819
|
+
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
|
+
if rpc_request is None:
|
|
823
|
+
raise RuntimeError("Internal error: rpc_request is None after validation")
|
|
283
824
|
|
|
284
|
-
#
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
},
|
|
292
|
-
request_id=None,
|
|
293
|
-
)
|
|
294
|
-
self.record_error_metrics(metrics, "unknown", "invalid_request", 0.0)
|
|
295
|
-
return error_response
|
|
825
|
+
# Create request context
|
|
826
|
+
ctx = RequestContext(
|
|
827
|
+
request_id=rpc_request.id,
|
|
828
|
+
start_time=start_time,
|
|
829
|
+
metrics=metrics,
|
|
830
|
+
rpc_request=rpc_request,
|
|
831
|
+
)
|
|
296
832
|
|
|
297
|
-
#
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
return
|
|
833
|
+
# Authenticate request if enabled
|
|
834
|
+
auth_result = await self._authenticate_request(request, ctx)
|
|
835
|
+
authenticated_agent_id, auth_error = auth_result
|
|
836
|
+
if auth_error is not None:
|
|
837
|
+
return auth_error
|
|
838
|
+
|
|
839
|
+
# Validate and extract envelope
|
|
840
|
+
envelope_result = self._validate_envelope(ctx)
|
|
841
|
+
envelope_or_none, result = envelope_result
|
|
842
|
+
if envelope_or_none is None:
|
|
843
|
+
# result is JSONResponse when envelope is None
|
|
844
|
+
return result # type: ignore[return-value]
|
|
845
|
+
envelope = envelope_or_none
|
|
846
|
+
# result is payload_type (str) when envelope is not None
|
|
847
|
+
payload_type = result # type: ignore[assignment]
|
|
302
848
|
|
|
303
|
-
#
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
self.record_error_metrics(
|
|
313
|
-
metrics, "unknown", "internal_error", time.perf_counter() - start_time
|
|
314
|
-
)
|
|
315
|
-
return error_response
|
|
316
|
-
|
|
317
|
-
# Verify authentication if enabled
|
|
318
|
-
authenticated_agent_id: str | None = None
|
|
319
|
-
if self.auth_middleware is not None:
|
|
320
|
-
try:
|
|
321
|
-
authenticated_agent_id = await self.auth_middleware.verify_authentication(
|
|
322
|
-
request
|
|
323
|
-
)
|
|
324
|
-
except HTTPException as e:
|
|
325
|
-
# Authentication failed - return JSON-RPC error
|
|
326
|
-
logger.warning(
|
|
327
|
-
"asap.request.auth_failed",
|
|
328
|
-
status_code=e.status_code,
|
|
329
|
-
detail=e.detail,
|
|
330
|
-
)
|
|
331
|
-
# Map HTTP status to JSON-RPC error code
|
|
332
|
-
error_code = INVALID_REQUEST if e.status_code == 401 else INVALID_PARAMS
|
|
333
|
-
error_response = self.build_error_response(
|
|
334
|
-
error_code,
|
|
335
|
-
data={"error": str(e.detail), "status_code": e.status_code},
|
|
336
|
-
request_id=rpc_request.id,
|
|
337
|
-
)
|
|
338
|
-
self.record_error_metrics(
|
|
339
|
-
metrics, "unknown", "auth_failed", time.perf_counter() - start_time
|
|
340
|
-
)
|
|
341
|
-
return error_response
|
|
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
|
|
342
858
|
|
|
343
|
-
# Validate
|
|
344
|
-
|
|
859
|
+
# Validate envelope timestamp to prevent replay attacks
|
|
860
|
+
try:
|
|
861
|
+
validate_envelope_timestamp(envelope)
|
|
862
|
+
except InvalidTimestampError as e:
|
|
345
863
|
logger.warning(
|
|
346
|
-
"asap.request.
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
INVALID_PARAMS,
|
|
351
|
-
data={
|
|
352
|
-
"error": "JSON-RPC 'params' must be an object",
|
|
353
|
-
"received_type": type(rpc_request.params).__name__,
|
|
354
|
-
},
|
|
355
|
-
request_id=rpc_request.id,
|
|
864
|
+
"asap.request.invalid_timestamp",
|
|
865
|
+
envelope_id=envelope.id,
|
|
866
|
+
error=e.message,
|
|
867
|
+
details=e.details,
|
|
356
868
|
)
|
|
869
|
+
duration_seconds = time.perf_counter() - ctx.start_time
|
|
357
870
|
self.record_error_metrics(
|
|
358
|
-
metrics,
|
|
871
|
+
ctx.metrics, payload_type, "invalid_timestamp", duration_seconds
|
|
359
872
|
)
|
|
360
|
-
return
|
|
361
|
-
|
|
362
|
-
# Extract envelope from params
|
|
363
|
-
envelope_data = rpc_request.params.get("envelope")
|
|
364
|
-
if envelope_data is None:
|
|
365
|
-
logger.warning("asap.request.missing_envelope")
|
|
366
|
-
error_response = self.build_error_response(
|
|
873
|
+
return self.build_error_response(
|
|
367
874
|
INVALID_PARAMS,
|
|
368
|
-
data={
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
875
|
+
data={
|
|
876
|
+
"error": "Invalid envelope timestamp",
|
|
877
|
+
"code": e.code,
|
|
878
|
+
"message": e.message,
|
|
879
|
+
"details": e.details,
|
|
880
|
+
},
|
|
881
|
+
request_id=ctx.request_id,
|
|
373
882
|
)
|
|
374
|
-
return error_response
|
|
375
883
|
|
|
376
|
-
# Validate envelope
|
|
884
|
+
# Validate envelope nonce if nonce store is available
|
|
377
885
|
try:
|
|
378
|
-
envelope
|
|
379
|
-
|
|
380
|
-
|
|
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)
|
|
381
890
|
logger.warning(
|
|
382
|
-
"asap.request.
|
|
383
|
-
|
|
384
|
-
|
|
891
|
+
"asap.request.invalid_nonce",
|
|
892
|
+
envelope_id=envelope.id,
|
|
893
|
+
nonce=nonce_sanitized,
|
|
894
|
+
error=e.message,
|
|
385
895
|
)
|
|
386
|
-
duration_seconds = time.perf_counter() - start_time
|
|
896
|
+
duration_seconds = time.perf_counter() - ctx.start_time
|
|
387
897
|
self.record_error_metrics(
|
|
388
|
-
metrics, payload_type, "
|
|
898
|
+
ctx.metrics, payload_type, "invalid_nonce", duration_seconds
|
|
389
899
|
)
|
|
390
900
|
return self.build_error_response(
|
|
391
901
|
INVALID_PARAMS,
|
|
392
902
|
data={
|
|
393
|
-
"error": "Invalid envelope
|
|
394
|
-
"
|
|
903
|
+
"error": "Invalid envelope nonce",
|
|
904
|
+
"code": e.code,
|
|
905
|
+
"message": e.message,
|
|
906
|
+
"details": e.details,
|
|
395
907
|
},
|
|
396
|
-
request_id=
|
|
908
|
+
request_id=ctx.request_id,
|
|
397
909
|
)
|
|
398
910
|
|
|
399
|
-
# Verify sender matches authenticated identity
|
|
400
|
-
if self.auth_middleware is not None:
|
|
401
|
-
try:
|
|
402
|
-
self.auth_middleware.verify_sender_matches_auth(
|
|
403
|
-
authenticated_agent_id, envelope.sender
|
|
404
|
-
)
|
|
405
|
-
except HTTPException as e:
|
|
406
|
-
# Sender mismatch - return JSON-RPC error
|
|
407
|
-
logger.warning(
|
|
408
|
-
"asap.request.sender_mismatch",
|
|
409
|
-
authenticated_agent=authenticated_agent_id,
|
|
410
|
-
envelope_sender=envelope.sender,
|
|
411
|
-
)
|
|
412
|
-
error_response = self.build_error_response(
|
|
413
|
-
INVALID_PARAMS,
|
|
414
|
-
data={"error": str(e.detail), "status_code": e.status_code},
|
|
415
|
-
request_id=rpc_request.id,
|
|
416
|
-
)
|
|
417
|
-
duration_seconds = time.perf_counter() - start_time
|
|
418
|
-
self.record_error_metrics(
|
|
419
|
-
metrics, payload_type, "sender_mismatch", duration_seconds
|
|
420
|
-
)
|
|
421
|
-
return error_response
|
|
422
|
-
|
|
423
911
|
# Log request received
|
|
424
912
|
logger.info(
|
|
425
913
|
"asap.request.received",
|
|
@@ -431,132 +919,45 @@ class ASAPRequestHandler:
|
|
|
431
919
|
authenticated=authenticated_agent_id is not None,
|
|
432
920
|
)
|
|
433
921
|
|
|
434
|
-
#
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
#
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
)
|
|
444
|
-
# Record error metric
|
|
445
|
-
duration_seconds = time.perf_counter() - start_time
|
|
446
|
-
metrics.increment_counter(
|
|
447
|
-
"asap_requests_total",
|
|
448
|
-
{"payload_type": payload_type, "status": "error"},
|
|
449
|
-
)
|
|
450
|
-
metrics.increment_counter(
|
|
451
|
-
"asap_requests_error_total",
|
|
452
|
-
{"payload_type": payload_type, "error_type": "handler_not_found"},
|
|
453
|
-
)
|
|
454
|
-
metrics.observe_histogram(
|
|
455
|
-
"asap_request_duration_seconds",
|
|
456
|
-
duration_seconds,
|
|
457
|
-
{"payload_type": payload_type, "status": "error"},
|
|
458
|
-
)
|
|
459
|
-
handler_error = JsonRpcErrorResponse(
|
|
460
|
-
error=JsonRpcError.from_code(
|
|
461
|
-
METHOD_NOT_FOUND,
|
|
462
|
-
data={
|
|
463
|
-
"payload_type": e.payload_type,
|
|
464
|
-
"error": str(e),
|
|
465
|
-
},
|
|
466
|
-
),
|
|
467
|
-
id=rpc_request.id,
|
|
468
|
-
)
|
|
469
|
-
return JSONResponse(
|
|
470
|
-
status_code=200,
|
|
471
|
-
content=handler_error.model_dump(),
|
|
472
|
-
)
|
|
473
|
-
|
|
474
|
-
# Calculate duration
|
|
475
|
-
duration_seconds = time.perf_counter() - start_time
|
|
476
|
-
duration_ms = duration_seconds * 1000
|
|
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]
|
|
477
931
|
|
|
478
|
-
#
|
|
479
|
-
|
|
480
|
-
"asap_requests_total",
|
|
481
|
-
{"payload_type": payload_type, "status": "success"},
|
|
482
|
-
)
|
|
483
|
-
metrics.increment_counter(
|
|
484
|
-
"asap_requests_success_total",
|
|
485
|
-
{"payload_type": payload_type},
|
|
486
|
-
)
|
|
487
|
-
metrics.observe_histogram(
|
|
488
|
-
"asap_request_duration_seconds",
|
|
489
|
-
duration_seconds,
|
|
490
|
-
{"payload_type": payload_type, "status": "success"},
|
|
491
|
-
)
|
|
492
|
-
|
|
493
|
-
# Log successful processing
|
|
494
|
-
logger.info(
|
|
495
|
-
"asap.request.processed",
|
|
496
|
-
envelope_id=envelope.id,
|
|
497
|
-
response_id=response_envelope.id,
|
|
498
|
-
trace_id=envelope.trace_id,
|
|
499
|
-
payload_type=envelope.payload_type,
|
|
500
|
-
duration_ms=round(duration_ms, 2),
|
|
501
|
-
)
|
|
502
|
-
|
|
503
|
-
# Wrap response in JSON-RPC
|
|
504
|
-
rpc_response = JsonRpcResponse(
|
|
505
|
-
result={"envelope": response_envelope.model_dump(mode="json")},
|
|
506
|
-
id=rpc_request.id,
|
|
507
|
-
)
|
|
508
|
-
|
|
509
|
-
return JSONResponse(
|
|
510
|
-
status_code=200,
|
|
511
|
-
content=rpc_response.model_dump(),
|
|
512
|
-
)
|
|
932
|
+
# Build and return success response
|
|
933
|
+
return self._build_success_response(response_envelope, ctx, payload_type)
|
|
513
934
|
|
|
514
935
|
except Exception as e:
|
|
515
|
-
#
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
duration_seconds,
|
|
531
|
-
{"payload_type": payload_type, "status": "error"},
|
|
532
|
-
)
|
|
533
|
-
|
|
534
|
-
# Log error
|
|
535
|
-
logger.exception(
|
|
536
|
-
"asap.request.error",
|
|
537
|
-
error=str(e),
|
|
538
|
-
error_type=type(e).__name__,
|
|
539
|
-
duration_ms=round(duration_ms, 2),
|
|
540
|
-
)
|
|
541
|
-
|
|
542
|
-
# Internal server error
|
|
543
|
-
internal_error = JsonRpcErrorResponse(
|
|
544
|
-
error=JsonRpcError.from_code(
|
|
545
|
-
INTERNAL_ERROR,
|
|
546
|
-
data={"error": str(e), "type": type(e).__name__},
|
|
547
|
-
),
|
|
548
|
-
id=None,
|
|
549
|
-
)
|
|
550
|
-
return JSONResponse(
|
|
551
|
-
status_code=200,
|
|
552
|
-
content=internal_error.model_dump(),
|
|
553
|
-
)
|
|
936
|
+
# Create minimal context for error handling if we don't have rpc_request yet
|
|
937
|
+
if "ctx" not in locals():
|
|
938
|
+
# Fallback: create context with minimal info
|
|
939
|
+
temp_metrics = get_metrics()
|
|
940
|
+
# JsonRpcRequest requires id to be str | int, use empty string as fallback
|
|
941
|
+
temp_rpc_request = JsonRpcRequest(
|
|
942
|
+
jsonrpc="2.0", method=ASAP_METHOD, params={}, id=""
|
|
943
|
+
)
|
|
944
|
+
ctx = RequestContext(
|
|
945
|
+
request_id="",
|
|
946
|
+
start_time=start_time,
|
|
947
|
+
metrics=temp_metrics,
|
|
948
|
+
rpc_request=temp_rpc_request,
|
|
949
|
+
)
|
|
950
|
+
return self._handle_internal_error(e, ctx, payload_type)
|
|
554
951
|
|
|
555
952
|
|
|
556
953
|
def create_app(
|
|
557
954
|
manifest: Manifest,
|
|
558
955
|
registry: HandlerRegistry | None = None,
|
|
559
956
|
token_validator: Callable[[str], str | None] | None = None,
|
|
957
|
+
rate_limit: str | None = None,
|
|
958
|
+
max_request_size: int | None = None,
|
|
959
|
+
max_threads: int | None = None,
|
|
960
|
+
require_nonce: bool = False,
|
|
560
961
|
) -> FastAPI:
|
|
561
962
|
"""Create and configure a FastAPI application for ASAP protocol.
|
|
562
963
|
|
|
@@ -576,6 +977,17 @@ def create_app(
|
|
|
576
977
|
token_validator: Optional function to validate Bearer tokens.
|
|
577
978
|
Required if manifest.auth is configured. Should return agent ID
|
|
578
979
|
if token is valid, None otherwise.
|
|
980
|
+
rate_limit: Optional rate limit string (e.g., "100/minute").
|
|
981
|
+
Rate limiting is IP-based (per client IP address) to prevent DoS attacks.
|
|
982
|
+
Defaults to ASAP_RATE_LIMIT environment variable or "100/minute".
|
|
983
|
+
max_request_size: Optional maximum request size in bytes.
|
|
984
|
+
Defaults to ASAP_MAX_REQUEST_SIZE environment variable or 10MB.
|
|
985
|
+
max_threads: Optional maximum number of threads for sync handlers.
|
|
986
|
+
Defaults to ASAP_MAX_THREADS environment variable or min(32, cpu_count + 4).
|
|
987
|
+
Set to None to use unbounded executor (not recommended for production).
|
|
988
|
+
require_nonce: If True, enables nonce validation for replay attack prevention.
|
|
989
|
+
When enabled, creates an InMemoryNonceStore and validates nonces in envelopes.
|
|
990
|
+
Defaults to False (nonce validation is optional).
|
|
579
991
|
|
|
580
992
|
Returns:
|
|
581
993
|
Configured FastAPI application ready to run
|
|
@@ -617,10 +1029,28 @@ def create_app(
|
|
|
617
1029
|
>>> app = create_app(manifest, registry)
|
|
618
1030
|
>>> # Run with uvicorn: uvicorn module:app
|
|
619
1031
|
"""
|
|
1032
|
+
# Configure thread pool executor for DoS prevention
|
|
1033
|
+
executor: BoundedExecutor | None = None
|
|
1034
|
+
if max_threads is None:
|
|
1035
|
+
max_threads_env = os.getenv("ASAP_MAX_THREADS")
|
|
1036
|
+
if max_threads_env:
|
|
1037
|
+
max_threads = int(max_threads_env)
|
|
1038
|
+
if max_threads is not None:
|
|
1039
|
+
executor = BoundedExecutor(max_threads=max_threads)
|
|
1040
|
+
logger.info(
|
|
1041
|
+
"asap.server.bounded_executor_enabled",
|
|
1042
|
+
manifest_id=manifest.id,
|
|
1043
|
+
max_threads=max_threads,
|
|
1044
|
+
)
|
|
1045
|
+
|
|
620
1046
|
# Use default registry if none provided
|
|
621
1047
|
if registry is None:
|
|
622
1048
|
registry = create_default_registry()
|
|
623
1049
|
|
|
1050
|
+
# Attach executor to registry if provided
|
|
1051
|
+
if executor is not None:
|
|
1052
|
+
registry._executor = executor
|
|
1053
|
+
|
|
624
1054
|
# Create authentication middleware if auth is configured
|
|
625
1055
|
auth_middleware: AuthenticationMiddleware | None = None
|
|
626
1056
|
if manifest.auth is not None:
|
|
@@ -637,14 +1067,54 @@ def create_app(
|
|
|
637
1067
|
schemes=manifest.auth.schemes,
|
|
638
1068
|
)
|
|
639
1069
|
|
|
1070
|
+
# Configure max request size
|
|
1071
|
+
if max_request_size is None:
|
|
1072
|
+
max_request_size = int(os.getenv("ASAP_MAX_REQUEST_SIZE", str(MAX_REQUEST_SIZE)))
|
|
1073
|
+
|
|
1074
|
+
# Create nonce store if required
|
|
1075
|
+
nonce_store: NonceStore | None = None
|
|
1076
|
+
if require_nonce:
|
|
1077
|
+
nonce_store = InMemoryNonceStore()
|
|
1078
|
+
logger.info(
|
|
1079
|
+
"asap.server.nonce_validation_enabled",
|
|
1080
|
+
manifest_id=manifest.id,
|
|
1081
|
+
)
|
|
1082
|
+
|
|
640
1083
|
# Create request handler
|
|
641
|
-
handler = ASAPRequestHandler(registry, manifest, auth_middleware)
|
|
1084
|
+
handler = ASAPRequestHandler(registry, manifest, auth_middleware, max_request_size, nonce_store)
|
|
642
1085
|
|
|
643
1086
|
app = FastAPI(
|
|
644
1087
|
title="ASAP Protocol Server",
|
|
645
1088
|
description=f"ASAP server for {manifest.name}",
|
|
646
1089
|
version=manifest.version,
|
|
647
1090
|
)
|
|
1091
|
+
|
|
1092
|
+
# Add size limit middleware (runs before routing)
|
|
1093
|
+
app.add_middleware(SizeLimitMiddleware, max_size=max_request_size)
|
|
1094
|
+
|
|
1095
|
+
# Configure rate limiting
|
|
1096
|
+
if rate_limit is None:
|
|
1097
|
+
rate_limit_str = os.getenv("ASAP_RATE_LIMIT", "100/minute")
|
|
1098
|
+
else:
|
|
1099
|
+
rate_limit_str = rate_limit
|
|
1100
|
+
|
|
1101
|
+
# Create isolated limiter instance for this app
|
|
1102
|
+
# This ensures each app instance has its own rate limiter storage
|
|
1103
|
+
# Tests can override this via monkeypatch or direct assignment to app.state.limiter
|
|
1104
|
+
app.state.limiter = create_limiter([rate_limit_str])
|
|
1105
|
+
app.state.max_request_size = max_request_size
|
|
1106
|
+
app.add_exception_handler(RateLimitExceeded, rate_limit_handler)
|
|
1107
|
+
logger.info(
|
|
1108
|
+
"asap.server.rate_limit_enabled",
|
|
1109
|
+
manifest_id=manifest.id,
|
|
1110
|
+
rate_limit=rate_limit_str,
|
|
1111
|
+
)
|
|
1112
|
+
logger.info(
|
|
1113
|
+
"asap.server.max_request_size",
|
|
1114
|
+
manifest_id=manifest.id,
|
|
1115
|
+
max_request_size=max_request_size,
|
|
1116
|
+
)
|
|
1117
|
+
|
|
648
1118
|
# Note: Request size limits should be configured at the ASGI server level (e.g., uvicorn).
|
|
649
1119
|
# For production, consider setting --limit-max-requests or using a reverse proxy
|
|
650
1120
|
# (nginx, traefik) to enforce request size limits (e.g., 10MB max).
|
|
@@ -686,6 +1156,7 @@ def create_app(
|
|
|
686
1156
|
)
|
|
687
1157
|
|
|
688
1158
|
@app.post("/asap")
|
|
1159
|
+
@limiter.limit(rate_limit_str) # slowapi uses app.state.limiter at runtime
|
|
689
1160
|
async def handle_asap_message(request: Request) -> JSONResponse:
|
|
690
1161
|
"""Handle ASAP messages wrapped in JSON-RPC 2.0.
|
|
691
1162
|
|
|
@@ -723,7 +1194,7 @@ def _create_default_manifest() -> Manifest:
|
|
|
723
1194
|
return Manifest(
|
|
724
1195
|
id="urn:asap:agent:default-server",
|
|
725
1196
|
name="ASAP Default Server",
|
|
726
|
-
version="0.
|
|
1197
|
+
version="0.3.0",
|
|
727
1198
|
description="Default ASAP protocol server with echo capabilities",
|
|
728
1199
|
capabilities=Capability(
|
|
729
1200
|
asap_version="0.1",
|