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/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 typing import Any, Callable
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.transport.middleware import AuthenticationMiddleware, BearerTokenValidator
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": payload_type, "status": "error"},
227
+ {"payload_type": normalized_payload_type, "status": "error"},
158
228
  )
159
229
  metrics.increment_counter(
160
230
  "asap_requests_error_total",
161
- {"payload_type": payload_type, "error_type": error_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": payload_type, "status": "error"},
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
- body: dict[str, Any] = await request.json()
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 ValueError as e:
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 body
272
- try:
273
- body = await self.parse_json_body(request)
274
- except ValueError as e:
275
- # Invalid JSON - return parse error
276
- error_response = self.build_error_response(
277
- PARSE_ERROR,
278
- data={"error": str(e)},
279
- request_id=None,
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
- # Validate body is a dict (JSON-RPC requires object at root)
285
- if not isinstance(body, dict):
286
- error_response = self.build_error_response(
287
- INVALID_REQUEST,
288
- data={
289
- "error": "JSON-RPC request must be an object",
290
- "received_type": type(body).__name__,
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
- # Validate JSON-RPC request structure and method
298
- rpc_request, validation_error = self.validate_jsonrpc_request(body)
299
- if validation_error is not None:
300
- self.record_error_metrics(metrics, "unknown", "invalid_request", 0.0)
301
- return validation_error
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
- # Type narrowing: rpc_request is not None here
304
- if rpc_request is None:
305
- # This should not happen if validate_jsonrpc_request is correct
306
- # but guard against it for robustness
307
- error_response = self.build_error_response(
308
- INTERNAL_ERROR,
309
- data={"error": "Internal validation error"},
310
- request_id=None,
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 params is a dict before accessing
344
- if not isinstance(rpc_request.params, dict):
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.invalid_params_type",
347
- params_type=type(rpc_request.params).__name__,
348
- )
349
- error_response = self.build_error_response(
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, "unknown", "invalid_params", time.perf_counter() - start_time
871
+ ctx.metrics, payload_type, "invalid_timestamp", duration_seconds
359
872
  )
360
- return error_response
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={"error": "Missing 'envelope' in params"},
369
- request_id=rpc_request.id,
370
- )
371
- self.record_error_metrics(
372
- metrics, "unknown", "missing_envelope", time.perf_counter() - start_time
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 structure
884
+ # Validate envelope nonce if nonce store is available
377
885
  try:
378
- envelope = Envelope(**envelope_data)
379
- payload_type = envelope.payload_type
380
- except ValidationError as e:
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.invalid_envelope",
383
- error="Invalid envelope structure",
384
- validation_errors=str(e.errors()),
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, "invalid_envelope", duration_seconds
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 structure",
394
- "validation_errors": e.errors(),
903
+ "error": "Invalid envelope nonce",
904
+ "code": e.code,
905
+ "message": e.message,
906
+ "details": e.details,
395
907
  },
396
- request_id=rpc_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
- # Process the envelope using the handler registry
435
- try:
436
- response_envelope = await self.registry.dispatch_async(envelope, self.manifest)
437
- except HandlerNotFoundError as e:
438
- # No handler registered for this payload type
439
- logger.warning(
440
- "asap.request.handler_not_found",
441
- payload_type=e.payload_type,
442
- envelope_id=envelope.id,
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
- # Record success metrics
479
- metrics.increment_counter(
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
- # Calculate duration for error case
516
- duration_seconds = time.perf_counter() - start_time
517
- duration_ms = duration_seconds * 1000
518
-
519
- # Record error metrics
520
- metrics.increment_counter(
521
- "asap_requests_total",
522
- {"payload_type": payload_type, "status": "error"},
523
- )
524
- metrics.increment_counter(
525
- "asap_requests_error_total",
526
- {"payload_type": payload_type, "error_type": "internal_error"},
527
- )
528
- metrics.observe_histogram(
529
- "asap_request_duration_seconds",
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.1.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",