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/__init__.py +1 -1
- asap/errors.py +42 -0
- asap/examples/coordinator.py +1 -1
- asap/examples/echo_agent.py +1 -1
- asap/models/constants.py +1 -0
- asap/observability/metrics.py +1 -0
- asap/transport/executors.py +156 -0
- asap/transport/handlers.py +17 -5
- asap/transport/middleware.py +298 -3
- asap/transport/server.py +668 -274
- {asap_protocol-0.1.0.dist-info → asap_protocol-0.3.0.dist-info}/METADATA +49 -73
- {asap_protocol-0.1.0.dist-info → asap_protocol-0.3.0.dist-info}/RECORD +15 -14
- {asap_protocol-0.1.0.dist-info → asap_protocol-0.3.0.dist-info}/WHEEL +0 -0
- {asap_protocol-0.1.0.dist-info → asap_protocol-0.3.0.dist-info}/entry_points.txt +0 -0
- {asap_protocol-0.1.0.dist-info → asap_protocol-0.3.0.dist-info}/licenses/LICENSE +0 -0
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
|
|
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
|
|
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":
|
|
217
|
+
{"payload_type": normalized_payload_type, "status": "error"},
|
|
158
218
|
)
|
|
159
219
|
metrics.increment_counter(
|
|
160
220
|
"asap_requests_error_total",
|
|
161
|
-
{"payload_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":
|
|
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
|
-
|
|
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
|
|
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
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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,
|
|
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
|
-
#
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
-
#
|
|
504
|
-
|
|
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
|
-
#
|
|
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
|
-
)
|
|
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.
|
|
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",
|