asap-protocol 0.1.0__py3-none-any.whl → 0.3.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,32 @@ 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 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.transport.middleware import (
60
+ AuthenticationMiddleware,
61
+ BearerTokenValidator,
62
+ SizeLimitMiddleware,
63
+ create_limiter,
64
+ limiter,
65
+ rate_limit_handler,
66
+ )
54
67
  from asap.observability.metrics import MetricsCollector
68
+ from asap.transport.executors import BoundedExecutor
55
69
  from asap.transport.handlers import (
56
70
  HandlerNotFoundError,
57
71
  HandlerRegistry,
@@ -73,6 +87,30 @@ from asap.transport.jsonrpc import (
73
87
  # Module logger
74
88
  logger = get_logger(__name__)
75
89
 
90
+ # Type variable for handler result pattern
91
+ T = TypeVar("T")
92
+ HandlerResult = tuple[T | None, JSONResponse | None]
93
+
94
+
95
+ @dataclass
96
+ class RequestContext:
97
+ """Request-scoped context for handler processing.
98
+
99
+ Groups request-scoped data that is passed to multiple helper methods
100
+ to reduce parameter noise and improve code readability.
101
+
102
+ Attributes:
103
+ request_id: JSON-RPC request ID (str, int, or None)
104
+ start_time: Request start time for duration calculation
105
+ metrics: Metrics collector for observability
106
+ rpc_request: Validated JSON-RPC request object
107
+ """
108
+
109
+ request_id: str | int | None
110
+ start_time: float
111
+ metrics: MetricsCollector
112
+ rpc_request: JsonRpcRequest
113
+
76
114
 
77
115
  class ASAPRequestHandler:
78
116
  """Handler for processing ASAP protocol requests.
@@ -103,6 +141,7 @@ class ASAPRequestHandler:
103
141
  registry: HandlerRegistry,
104
142
  manifest: Manifest,
105
143
  auth_middleware: AuthenticationMiddleware | None = None,
144
+ max_request_size: int = MAX_REQUEST_SIZE,
106
145
  ) -> None:
107
146
  """Initialize the request handler.
108
147
 
@@ -110,10 +149,29 @@ class ASAPRequestHandler:
110
149
  registry: Handler registry for dispatching payloads
111
150
  manifest: Agent manifest describing capabilities
112
151
  auth_middleware: Optional authentication middleware for request validation
152
+ max_request_size: Maximum allowed request size in bytes
113
153
  """
114
154
  self.registry = registry
115
155
  self.manifest = manifest
116
156
  self.auth_middleware = auth_middleware
157
+ self.max_request_size = max_request_size
158
+
159
+ def _normalize_payload_type_for_metrics(self, payload_type: str) -> str:
160
+ """Normalize payload type for metrics to prevent cardinality explosion.
161
+
162
+ Only registered payload types are used as metric labels. Unknown
163
+ payload types are normalized to "other" to prevent DoS attacks
164
+ through metric cardinality explosion.
165
+
166
+ Args:
167
+ payload_type: The payload type to normalize
168
+
169
+ Returns:
170
+ The payload type if registered, or "other" if unknown
171
+ """
172
+ if self.registry.has_handler(payload_type):
173
+ return payload_type
174
+ return "other"
117
175
 
118
176
  def build_error_response(
119
177
  self,
@@ -152,22 +210,475 @@ class ASAPRequestHandler:
152
210
  error_type: Type of error that occurred
153
211
  duration_seconds: Request duration in seconds
154
212
  """
213
+ # Normalize payload_type to prevent cardinality explosion
214
+ normalized_payload_type = self._normalize_payload_type_for_metrics(payload_type)
155
215
  metrics.increment_counter(
156
216
  "asap_requests_total",
157
- {"payload_type": payload_type, "status": "error"},
217
+ {"payload_type": normalized_payload_type, "status": "error"},
158
218
  )
159
219
  metrics.increment_counter(
160
220
  "asap_requests_error_total",
161
- {"payload_type": payload_type, "error_type": error_type},
221
+ {"payload_type": normalized_payload_type, "error_type": error_type},
162
222
  )
163
223
  metrics.observe_histogram(
164
224
  "asap_request_duration_seconds",
165
225
  duration_seconds,
166
- {"payload_type": payload_type, "status": "error"},
226
+ {"payload_type": normalized_payload_type, "status": "error"},
167
227
  )
168
228
 
229
+ def _validate_envelope(
230
+ self,
231
+ ctx: RequestContext,
232
+ ) -> tuple[Envelope | None, JSONResponse | str]:
233
+ """Validate and extract envelope from JSON-RPC params.
234
+
235
+ Validates that params is a dict, extracts the envelope field,
236
+ and validates the envelope structure.
237
+
238
+ Args:
239
+ ctx: Request context with rpc_request, start_time, and metrics
240
+
241
+ Returns:
242
+ Tuple of (Envelope, payload_type) if valid, or (None, error_response) if invalid
243
+ """
244
+ rpc_request = ctx.rpc_request
245
+ # Validate params is a dict before accessing
246
+ if not isinstance(rpc_request.params, dict):
247
+ logger.warning(
248
+ "asap.request.invalid_params_type",
249
+ params_type=type(rpc_request.params).__name__,
250
+ )
251
+ error_response = self.build_error_response(
252
+ INVALID_PARAMS,
253
+ data={
254
+ "error": "JSON-RPC 'params' must be an object",
255
+ "received_type": type(rpc_request.params).__name__,
256
+ },
257
+ request_id=ctx.request_id,
258
+ )
259
+ self.record_error_metrics(
260
+ ctx.metrics,
261
+ "unknown",
262
+ "invalid_params",
263
+ time.perf_counter() - ctx.start_time,
264
+ )
265
+ return None, error_response
266
+
267
+ # Extract envelope from params
268
+ envelope_data = rpc_request.params.get("envelope")
269
+ if envelope_data is None:
270
+ logger.warning("asap.request.missing_envelope")
271
+ error_response = self.build_error_response(
272
+ INVALID_PARAMS,
273
+ data={"error": "Missing 'envelope' in params"},
274
+ request_id=ctx.request_id,
275
+ )
276
+ self.record_error_metrics(
277
+ ctx.metrics,
278
+ "unknown",
279
+ "missing_envelope",
280
+ time.perf_counter() - ctx.start_time,
281
+ )
282
+ return None, error_response
283
+
284
+ # Validate envelope structure
285
+ try:
286
+ envelope = Envelope(**envelope_data)
287
+ payload_type = envelope.payload_type
288
+ return envelope, payload_type
289
+ except ValidationError as e:
290
+ logger.warning(
291
+ "asap.request.invalid_envelope",
292
+ error="Invalid envelope structure",
293
+ validation_errors=str(e.errors()),
294
+ )
295
+ duration_seconds = time.perf_counter() - ctx.start_time
296
+ self.record_error_metrics(ctx.metrics, "unknown", "invalid_envelope", duration_seconds)
297
+ error_response = self.build_error_response(
298
+ INVALID_PARAMS,
299
+ data={
300
+ "error": "Invalid envelope structure",
301
+ "validation_errors": e.errors(),
302
+ },
303
+ request_id=ctx.request_id,
304
+ )
305
+ return None, error_response
306
+
307
+ async def _dispatch_to_handler(
308
+ self,
309
+ envelope: Envelope,
310
+ ctx: RequestContext,
311
+ ) -> tuple[Envelope | None, JSONResponse | str]:
312
+ """Dispatch envelope to registered handler.
313
+
314
+ Looks up and executes the handler for the envelope's payload type.
315
+ Handles HandlerNotFoundError and converts it to JSON-RPC error response.
316
+
317
+ Args:
318
+ envelope: Validated ASAP envelope
319
+ ctx: Request context with rpc_request, start_time, and metrics
320
+
321
+ Returns:
322
+ Tuple of (response_envelope, payload_type) if successful,
323
+ or (None, error_response) if handler not found
324
+ """
325
+ payload_type = envelope.payload_type
326
+ try:
327
+ response_envelope = await self.registry.dispatch_async(envelope, self.manifest)
328
+ return response_envelope, payload_type
329
+ except ThreadPoolExhaustedError as e:
330
+ # Thread pool exhausted - service temporarily unavailable
331
+ logger.warning(
332
+ "asap.request.thread_pool_exhausted",
333
+ payload_type=payload_type,
334
+ envelope_id=envelope.id,
335
+ max_threads=e.max_threads,
336
+ active_threads=e.active_threads,
337
+ )
338
+ # Record error metric
339
+ duration_seconds = time.perf_counter() - ctx.start_time
340
+ self.record_error_metrics(
341
+ ctx.metrics, payload_type, "thread_pool_exhausted", duration_seconds
342
+ )
343
+ # Return HTTP 503 Service Unavailable (not JSON-RPC error)
344
+ error_response = JSONResponse(
345
+ status_code=503,
346
+ content={
347
+ "error": "Service Temporarily Unavailable",
348
+ "code": e.code,
349
+ "message": e.message,
350
+ "details": e.details,
351
+ },
352
+ )
353
+ return None, error_response
354
+ except HandlerNotFoundError as e:
355
+ # No handler registered for this payload type
356
+ logger.warning(
357
+ "asap.request.handler_not_found",
358
+ payload_type=e.payload_type,
359
+ envelope_id=envelope.id,
360
+ )
361
+ # Record error metric
362
+ duration_seconds = time.perf_counter() - ctx.start_time
363
+ self.record_error_metrics(
364
+ ctx.metrics, payload_type, "handler_not_found", duration_seconds
365
+ )
366
+ handler_error = JsonRpcErrorResponse(
367
+ error=JsonRpcError.from_code(
368
+ METHOD_NOT_FOUND,
369
+ data={
370
+ "payload_type": e.payload_type,
371
+ "error": str(e),
372
+ },
373
+ ),
374
+ id=ctx.request_id,
375
+ )
376
+ error_response = JSONResponse(
377
+ status_code=200,
378
+ content=handler_error.model_dump(),
379
+ )
380
+ return None, error_response
381
+
382
+ async def _authenticate_request(
383
+ self,
384
+ request: Request,
385
+ ctx: RequestContext,
386
+ ) -> HandlerResult[str]:
387
+ """Authenticate the request if authentication is enabled.
388
+
389
+ Args:
390
+ request: FastAPI request object
391
+ ctx: Request context with rpc_request, start_time, and metrics
392
+
393
+ Returns:
394
+ Tuple of (authenticated_agent_id, None) if successful or auth disabled,
395
+ or (None, error_response) if authentication failed
396
+ """
397
+ if self.auth_middleware is None:
398
+ return None, None
399
+
400
+ try:
401
+ authenticated_agent_id = await self.auth_middleware.verify_authentication(request)
402
+ return authenticated_agent_id, None
403
+ except HTTPException as e:
404
+ # Authentication failed - return JSON-RPC error
405
+ logger.warning(
406
+ "asap.request.auth_failed",
407
+ status_code=e.status_code,
408
+ detail=e.detail,
409
+ )
410
+ # Map HTTP status to JSON-RPC error code
411
+ error_code = INVALID_REQUEST if e.status_code == 401 else INVALID_PARAMS
412
+ error_response = self.build_error_response(
413
+ error_code,
414
+ data={"error": str(e.detail), "status_code": e.status_code},
415
+ request_id=ctx.request_id,
416
+ )
417
+ self.record_error_metrics(
418
+ ctx.metrics,
419
+ "unknown",
420
+ "auth_failed",
421
+ time.perf_counter() - ctx.start_time,
422
+ )
423
+ return None, error_response
424
+
425
+ def _verify_sender_matches_auth(
426
+ self,
427
+ authenticated_agent_id: str | None,
428
+ envelope: Envelope,
429
+ ctx: RequestContext,
430
+ payload_type: str,
431
+ ) -> JSONResponse | None:
432
+ """Verify that envelope sender matches authenticated identity.
433
+
434
+ Args:
435
+ authenticated_agent_id: Authenticated agent ID from auth middleware
436
+ envelope: Validated ASAP envelope
437
+ ctx: Request context with rpc_request, start_time, and metrics
438
+ payload_type: Payload type for metrics
439
+
440
+ Returns:
441
+ None if verification passes, or error_response if sender mismatch
442
+ """
443
+ if self.auth_middleware is None:
444
+ return None
445
+
446
+ try:
447
+ self.auth_middleware.verify_sender_matches_auth(authenticated_agent_id, envelope.sender)
448
+ return None
449
+ except HTTPException as e:
450
+ # Sender mismatch - return JSON-RPC error
451
+ logger.warning(
452
+ "asap.request.sender_mismatch",
453
+ authenticated_agent=authenticated_agent_id,
454
+ envelope_sender=envelope.sender,
455
+ )
456
+ error_response = self.build_error_response(
457
+ INVALID_PARAMS,
458
+ data={"error": str(e.detail), "status_code": e.status_code},
459
+ request_id=ctx.request_id,
460
+ )
461
+ duration_seconds = time.perf_counter() - ctx.start_time
462
+ self.record_error_metrics(
463
+ ctx.metrics, payload_type, "sender_mismatch", duration_seconds
464
+ )
465
+ return error_response
466
+
467
+ def _build_success_response(
468
+ self,
469
+ response_envelope: Envelope,
470
+ ctx: RequestContext,
471
+ payload_type: str,
472
+ ) -> JSONResponse:
473
+ """Build success response with metrics and logging.
474
+
475
+ Args:
476
+ response_envelope: Response envelope from handler
477
+ ctx: Request context with rpc_request, start_time, and metrics
478
+ payload_type: Payload type for metrics
479
+
480
+ Returns:
481
+ JSON-RPC success response
482
+ """
483
+ duration_seconds = time.perf_counter() - ctx.start_time
484
+ duration_ms = duration_seconds * 1000
485
+
486
+ # Normalize payload_type to prevent cardinality explosion
487
+ normalized_payload_type = self._normalize_payload_type_for_metrics(payload_type)
488
+
489
+ # Record success metrics
490
+ ctx.metrics.increment_counter(
491
+ "asap_requests_total",
492
+ {"payload_type": normalized_payload_type, "status": "success"},
493
+ )
494
+ ctx.metrics.increment_counter(
495
+ "asap_requests_success_total",
496
+ {"payload_type": normalized_payload_type},
497
+ )
498
+ ctx.metrics.observe_histogram(
499
+ "asap_request_duration_seconds",
500
+ duration_seconds,
501
+ {"payload_type": normalized_payload_type, "status": "success"},
502
+ )
503
+
504
+ # Log successful processing
505
+ logger.info(
506
+ "asap.request.processed",
507
+ envelope_id=response_envelope.id,
508
+ response_id=response_envelope.id,
509
+ trace_id=response_envelope.trace_id,
510
+ payload_type=payload_type,
511
+ duration_ms=round(duration_ms, 2),
512
+ )
513
+
514
+ # Wrap response in JSON-RPC
515
+ # JsonRpcResponse requires id to be str | int, not None
516
+ response_id: str | int = ctx.request_id if ctx.request_id is not None else ""
517
+ rpc_response = JsonRpcResponse(
518
+ result={"envelope": response_envelope.model_dump(mode="json")},
519
+ id=response_id,
520
+ )
521
+
522
+ return JSONResponse(
523
+ status_code=200,
524
+ content=rpc_response.model_dump(),
525
+ )
526
+
527
+ def _handle_internal_error(
528
+ self,
529
+ error: Exception,
530
+ ctx: RequestContext,
531
+ payload_type: str,
532
+ ) -> JSONResponse:
533
+ """Handle internal server errors with metrics and logging.
534
+
535
+ Args:
536
+ error: The exception that occurred
537
+ ctx: Request context with rpc_request, start_time, and metrics
538
+ payload_type: Payload type for metrics
539
+
540
+ Returns:
541
+ JSON-RPC internal error response
542
+ """
543
+ duration_seconds = time.perf_counter() - ctx.start_time
544
+ duration_ms = duration_seconds * 1000
545
+
546
+ # Record error metrics (normalized to prevent cardinality explosion)
547
+ self.record_error_metrics(ctx.metrics, payload_type, "internal_error", duration_seconds)
548
+
549
+ # Log error
550
+ logger.exception(
551
+ "asap.request.error",
552
+ error=str(error),
553
+ error_type=type(error).__name__,
554
+ duration_ms=round(duration_ms, 2),
555
+ )
556
+
557
+ # Internal server error
558
+ internal_error = JsonRpcErrorResponse(
559
+ error=JsonRpcError.from_code(
560
+ INTERNAL_ERROR,
561
+ data={"error": str(error), "type": type(error).__name__},
562
+ ),
563
+ id=ctx.request_id,
564
+ )
565
+ return JSONResponse(
566
+ status_code=200,
567
+ content=internal_error.model_dump(),
568
+ )
569
+
570
+ async def _parse_and_validate_request(
571
+ self,
572
+ request: Request,
573
+ ) -> HandlerResult[JsonRpcRequest]:
574
+ """Parse JSON body and validate JSON-RPC request structure.
575
+
576
+ Args:
577
+ request: FastAPI request object
578
+
579
+ Returns:
580
+ Tuple of (JsonRpcRequest, None) if valid, or (None, error_response) if invalid
581
+ """
582
+ # Parse JSON body
583
+ try:
584
+ body = await self.parse_json_body(request)
585
+ except HTTPException as e:
586
+ # HTTPException (e.g., 413 Payload Too Large) should be returned directly
587
+ # Don't convert to JSON-RPC error response
588
+ from fastapi.responses import JSONResponse
589
+
590
+ return None, JSONResponse(
591
+ status_code=e.status_code,
592
+ content={"detail": e.detail},
593
+ headers=e.headers if hasattr(e, "headers") else None,
594
+ )
595
+ except ValueError as e:
596
+ # Invalid JSON - return parse error
597
+ error_response = self.build_error_response(
598
+ PARSE_ERROR,
599
+ data={"error": str(e)},
600
+ request_id=None,
601
+ )
602
+ # Create temporary context for metrics (before we have rpc_request)
603
+ temp_metrics = get_metrics()
604
+ self.record_error_metrics(temp_metrics, "unknown", "parse_error", 0.0)
605
+ return None, error_response
606
+
607
+ # Validate body is a dict (JSON-RPC requires object at root)
608
+ if not isinstance(body, dict):
609
+ error_response = self.build_error_response(
610
+ INVALID_REQUEST,
611
+ data={
612
+ "error": "JSON-RPC request must be an object",
613
+ "received_type": type(body).__name__,
614
+ },
615
+ request_id=None,
616
+ )
617
+ temp_metrics = get_metrics()
618
+ self.record_error_metrics(temp_metrics, "unknown", "invalid_request", 0.0)
619
+ return None, error_response
620
+
621
+ # Validate JSON-RPC request structure and method
622
+ rpc_request, validation_error = self.validate_jsonrpc_request(body)
623
+ if validation_error is not None:
624
+ temp_metrics = get_metrics()
625
+ self.record_error_metrics(temp_metrics, "unknown", "invalid_request", 0.0)
626
+ return None, validation_error
627
+
628
+ # Type narrowing: rpc_request is not None here
629
+ if rpc_request is None:
630
+ # This should not happen if validate_jsonrpc_request is correct
631
+ # but guard against it for robustness
632
+ error_response = self.build_error_response(
633
+ INTERNAL_ERROR,
634
+ data={"error": "Internal validation error"},
635
+ request_id=None,
636
+ )
637
+ temp_metrics = get_metrics()
638
+ self.record_error_metrics(
639
+ temp_metrics, "unknown", "internal_error", time.perf_counter()
640
+ )
641
+ return None, error_response
642
+
643
+ return rpc_request, None
644
+
645
+ def _validate_request_size(self, request: Request, max_size: int) -> None:
646
+ """Validate that request size does not exceed maximum.
647
+
648
+ Checks Content-Length header first, then validates actual body size
649
+ if available. Raises HTTPException(413) if request is too large.
650
+
651
+ Args:
652
+ request: FastAPI request object
653
+ max_size: Maximum allowed request size in bytes
654
+
655
+ Raises:
656
+ HTTPException: If request size exceeds maximum (413 Payload Too Large)
657
+ """
658
+ # Check Content-Length header first
659
+ content_length = request.headers.get("content-length")
660
+ if content_length:
661
+ try:
662
+ size = int(content_length)
663
+ if size > max_size:
664
+ logger.warning(
665
+ "asap.request.size_exceeded",
666
+ content_length=size,
667
+ max_size=max_size,
668
+ )
669
+ raise HTTPException(
670
+ status_code=413,
671
+ detail=f"Request size ({size} bytes) exceeds maximum ({max_size} bytes)",
672
+ )
673
+ except ValueError:
674
+ # Invalid Content-Length header, will check body size instead
675
+ pass
676
+
169
677
  async def parse_json_body(self, request: Request) -> dict[str, Any]:
170
- """Parse JSON body from request.
678
+ """Parse JSON body from request with size validation.
679
+
680
+ Validates request size before parsing to prevent DoS attacks.
681
+ Checks both Content-Length header and actual body size.
171
682
 
172
683
  Args:
173
684
  request: FastAPI request object
@@ -176,14 +687,37 @@ class ASAPRequestHandler:
176
687
  Parsed JSON body
177
688
 
178
689
  Raises:
690
+ HTTPException: If request size exceeds maximum (413)
179
691
  ValueError: If JSON is invalid
180
692
  """
693
+ # Read body in chunks and validate size incrementally to prevent OOM attacks
694
+ # Note: Content-Length header validation is handled by SizeLimitMiddleware
695
+ # This validates actual body size during streaming to prevent OOM attacks
181
696
  try:
182
- body: dict[str, Any] = await request.json()
697
+ body_bytes = bytearray()
698
+ async for chunk in request.stream():
699
+ body_bytes.extend(chunk)
700
+ # Validate size after each chunk to abort early if limit exceeded
701
+ if len(body_bytes) > self.max_request_size:
702
+ logger.warning(
703
+ "asap.request.size_exceeded",
704
+ actual_size=len(body_bytes),
705
+ max_size=self.max_request_size,
706
+ )
707
+ raise HTTPException(
708
+ status_code=413,
709
+ detail=f"Request size ({len(body_bytes)} bytes) exceeds maximum ({self.max_request_size} bytes)",
710
+ )
711
+
712
+ # Parse JSON from bytes
713
+ body: dict[str, Any] = json.loads(body_bytes.decode("utf-8"))
183
714
  return body
184
- except ValueError as e:
715
+ except UnicodeDecodeError as e:
716
+ logger.warning("asap.request.invalid_encoding", error=str(e))
717
+ raise ValueError(f"Invalid UTF-8 encoding: {e}") from e
718
+ except json.JSONDecodeError as e:
185
719
  logger.warning("asap.request.invalid_json", error=str(e))
186
- raise
720
+ raise ValueError(f"Invalid JSON: {e}") from e
187
721
 
188
722
  def validate_jsonrpc_request(
189
723
  self, body: dict[str, Any]
@@ -268,157 +802,47 @@ class ASAPRequestHandler:
268
802
  payload_type = "unknown"
269
803
 
270
804
  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
283
-
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
296
-
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
302
-
805
+ # Parse and validate JSON-RPC request
806
+ parse_result = await self._parse_and_validate_request(request)
807
+ rpc_request, parse_error = parse_result
808
+ if parse_error is not None:
809
+ return parse_error
303
810
  # 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
811
+ assert rpc_request is not None
342
812
 
343
- # Validate params is a dict before accessing
344
- if not isinstance(rpc_request.params, dict):
345
- 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,
356
- )
357
- self.record_error_metrics(
358
- metrics, "unknown", "invalid_params", time.perf_counter() - start_time
359
- )
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(
367
- 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
373
- )
374
- return error_response
813
+ # Create request context
814
+ ctx = RequestContext(
815
+ request_id=rpc_request.id,
816
+ start_time=start_time,
817
+ metrics=metrics,
818
+ rpc_request=rpc_request,
819
+ )
375
820
 
376
- # Validate envelope structure
377
- try:
378
- envelope = Envelope(**envelope_data)
379
- payload_type = envelope.payload_type
380
- except ValidationError as e:
381
- logger.warning(
382
- "asap.request.invalid_envelope",
383
- error="Invalid envelope structure",
384
- validation_errors=str(e.errors()),
385
- )
386
- duration_seconds = time.perf_counter() - start_time
387
- self.record_error_metrics(
388
- metrics, payload_type, "invalid_envelope", duration_seconds
389
- )
390
- return self.build_error_response(
391
- INVALID_PARAMS,
392
- data={
393
- "error": "Invalid envelope structure",
394
- "validation_errors": e.errors(),
395
- },
396
- request_id=rpc_request.id,
397
- )
821
+ # Authenticate request if enabled
822
+ auth_result = await self._authenticate_request(request, ctx)
823
+ authenticated_agent_id, auth_error = auth_result
824
+ if auth_error is not None:
825
+ return auth_error
826
+
827
+ # Validate and extract envelope
828
+ envelope_result = self._validate_envelope(ctx)
829
+ envelope_or_none, result = envelope_result
830
+ if envelope_or_none is None:
831
+ # result is JSONResponse when envelope is None
832
+ return result # type: ignore[return-value]
833
+ envelope = envelope_or_none
834
+ # result is payload_type (str) when envelope is not None
835
+ payload_type = result # type: ignore[assignment]
398
836
 
399
837
  # 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
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
422
846
 
423
847
  # Log request received
424
848
  logger.info(
@@ -431,132 +855,44 @@ class ASAPRequestHandler:
431
855
  authenticated=authenticated_agent_id is not None,
432
856
  )
433
857
 
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
477
-
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
- )
858
+ # Dispatch to handler
859
+ dispatch_result = await self._dispatch_to_handler(envelope, ctx)
860
+ response_or_none, result = dispatch_result
861
+ if response_or_none is None:
862
+ # result is JSONResponse when response is None
863
+ return result # type: ignore[return-value]
864
+ response_envelope = response_or_none
865
+ # result is payload_type (str) when response is not None
866
+ payload_type = result # type: ignore[assignment]
502
867
 
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
- )
868
+ # Build and return success response
869
+ return self._build_success_response(response_envelope, ctx, payload_type)
513
870
 
514
871
  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
- )
872
+ # Create minimal context for error handling if we don't have rpc_request yet
873
+ if "ctx" not in locals():
874
+ # Fallback: create context with minimal info
875
+ temp_metrics = get_metrics()
876
+ # JsonRpcRequest requires id to be str | int, use empty string as fallback
877
+ temp_rpc_request = JsonRpcRequest(
878
+ jsonrpc="2.0", method=ASAP_METHOD, params={}, id=""
879
+ )
880
+ ctx = RequestContext(
881
+ request_id="",
882
+ start_time=start_time,
883
+ metrics=temp_metrics,
884
+ rpc_request=temp_rpc_request,
885
+ )
886
+ return self._handle_internal_error(e, ctx, payload_type)
554
887
 
555
888
 
556
889
  def create_app(
557
890
  manifest: Manifest,
558
891
  registry: HandlerRegistry | None = None,
559
892
  token_validator: Callable[[str], str | None] | None = None,
893
+ rate_limit: str | None = None,
894
+ max_request_size: int | None = None,
895
+ max_threads: int | None = None,
560
896
  ) -> FastAPI:
561
897
  """Create and configure a FastAPI application for ASAP protocol.
562
898
 
@@ -576,6 +912,14 @@ def create_app(
576
912
  token_validator: Optional function to validate Bearer tokens.
577
913
  Required if manifest.auth is configured. Should return agent ID
578
914
  if token is valid, None otherwise.
915
+ rate_limit: Optional rate limit string (e.g., "100/minute").
916
+ Rate limiting is IP-based (per client IP address) to prevent DoS attacks.
917
+ Defaults to ASAP_RATE_LIMIT environment variable or "100/minute".
918
+ max_request_size: Optional maximum request size in bytes.
919
+ Defaults to ASAP_MAX_REQUEST_SIZE environment variable or 10MB.
920
+ max_threads: Optional maximum number of threads for sync handlers.
921
+ Defaults to ASAP_MAX_THREADS environment variable or min(32, cpu_count + 4).
922
+ Set to None to use unbounded executor (not recommended for production).
579
923
 
580
924
  Returns:
581
925
  Configured FastAPI application ready to run
@@ -617,10 +961,28 @@ def create_app(
617
961
  >>> app = create_app(manifest, registry)
618
962
  >>> # Run with uvicorn: uvicorn module:app
619
963
  """
964
+ # Configure thread pool executor for DoS prevention
965
+ executor: BoundedExecutor | None = None
966
+ if max_threads is None:
967
+ max_threads_env = os.getenv("ASAP_MAX_THREADS")
968
+ if max_threads_env:
969
+ max_threads = int(max_threads_env)
970
+ if max_threads is not None:
971
+ executor = BoundedExecutor(max_threads=max_threads)
972
+ logger.info(
973
+ "asap.server.bounded_executor_enabled",
974
+ manifest_id=manifest.id,
975
+ max_threads=max_threads,
976
+ )
977
+
620
978
  # Use default registry if none provided
621
979
  if registry is None:
622
980
  registry = create_default_registry()
623
981
 
982
+ # Attach executor to registry if provided
983
+ if executor is not None:
984
+ registry._executor = executor
985
+
624
986
  # Create authentication middleware if auth is configured
625
987
  auth_middleware: AuthenticationMiddleware | None = None
626
988
  if manifest.auth is not None:
@@ -637,14 +999,45 @@ def create_app(
637
999
  schemes=manifest.auth.schemes,
638
1000
  )
639
1001
 
1002
+ # Configure max request size
1003
+ if max_request_size is None:
1004
+ max_request_size = int(os.getenv("ASAP_MAX_REQUEST_SIZE", str(MAX_REQUEST_SIZE)))
1005
+
640
1006
  # Create request handler
641
- handler = ASAPRequestHandler(registry, manifest, auth_middleware)
1007
+ handler = ASAPRequestHandler(registry, manifest, auth_middleware, max_request_size)
642
1008
 
643
1009
  app = FastAPI(
644
1010
  title="ASAP Protocol Server",
645
1011
  description=f"ASAP server for {manifest.name}",
646
1012
  version=manifest.version,
647
1013
  )
1014
+
1015
+ # Add size limit middleware (runs before routing)
1016
+ app.add_middleware(SizeLimitMiddleware, max_size=max_request_size)
1017
+
1018
+ # Configure rate limiting
1019
+ if rate_limit is None:
1020
+ rate_limit_str = os.getenv("ASAP_RATE_LIMIT", "100/minute")
1021
+ else:
1022
+ rate_limit_str = rate_limit
1023
+
1024
+ # Create isolated limiter instance for this app
1025
+ # This ensures each app instance has its own rate limiter storage
1026
+ # Tests can override this via monkeypatch or direct assignment to app.state.limiter
1027
+ app.state.limiter = create_limiter([rate_limit_str])
1028
+ app.state.max_request_size = max_request_size
1029
+ app.add_exception_handler(RateLimitExceeded, rate_limit_handler)
1030
+ logger.info(
1031
+ "asap.server.rate_limit_enabled",
1032
+ manifest_id=manifest.id,
1033
+ rate_limit=rate_limit_str,
1034
+ )
1035
+ logger.info(
1036
+ "asap.server.max_request_size",
1037
+ manifest_id=manifest.id,
1038
+ max_request_size=max_request_size,
1039
+ )
1040
+
648
1041
  # Note: Request size limits should be configured at the ASGI server level (e.g., uvicorn).
649
1042
  # For production, consider setting --limit-max-requests or using a reverse proxy
650
1043
  # (nginx, traefik) to enforce request size limits (e.g., 10MB max).
@@ -686,6 +1079,7 @@ def create_app(
686
1079
  )
687
1080
 
688
1081
  @app.post("/asap")
1082
+ @limiter.limit(rate_limit_str) # slowapi uses app.state.limiter at runtime
689
1083
  async def handle_asap_message(request: Request) -> JSONResponse:
690
1084
  """Handle ASAP messages wrapped in JSON-RPC 2.0.
691
1085
 
@@ -723,7 +1117,7 @@ def _create_default_manifest() -> Manifest:
723
1117
  return Manifest(
724
1118
  id="urn:asap:agent:default-server",
725
1119
  name="ASAP Default Server",
726
- version="0.1.0",
1120
+ version="0.3.0",
727
1121
  description="Default ASAP protocol server with echo capabilities",
728
1122
  capabilities=Capability(
729
1123
  asap_version="0.1",