asap-protocol 0.3.0__py3-none-any.whl → 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- asap/__init__.py +1 -1
- asap/cli.py +137 -2
- asap/errors.py +167 -0
- asap/examples/README.md +81 -10
- asap/examples/auth_patterns.py +212 -0
- asap/examples/error_recovery.py +248 -0
- asap/examples/long_running.py +287 -0
- asap/examples/mcp_integration.py +240 -0
- asap/examples/multi_step_workflow.py +134 -0
- asap/examples/orchestration.py +293 -0
- asap/examples/rate_limiting.py +137 -0
- asap/examples/run_demo.py +9 -4
- asap/examples/secure_handler.py +84 -0
- asap/examples/state_migration.py +240 -0
- asap/examples/streaming_response.py +108 -0
- asap/examples/websocket_concept.py +129 -0
- asap/mcp/__init__.py +43 -0
- asap/mcp/client.py +224 -0
- asap/mcp/protocol.py +179 -0
- asap/mcp/server.py +333 -0
- asap/mcp/server_runner.py +40 -0
- asap/models/__init__.py +4 -0
- asap/models/base.py +0 -3
- asap/models/constants.py +76 -1
- asap/models/entities.py +58 -7
- asap/models/envelope.py +14 -1
- asap/models/ids.py +8 -4
- asap/models/parts.py +33 -3
- asap/models/validators.py +16 -0
- asap/observability/__init__.py +6 -0
- asap/observability/dashboards/README.md +24 -0
- asap/observability/dashboards/asap-detailed.json +131 -0
- asap/observability/dashboards/asap-red.json +129 -0
- asap/observability/logging.py +81 -1
- asap/observability/metrics.py +15 -1
- asap/observability/trace_parser.py +238 -0
- asap/observability/trace_ui.py +218 -0
- asap/observability/tracing.py +293 -0
- asap/state/machine.py +15 -2
- asap/state/snapshot.py +0 -9
- asap/testing/__init__.py +31 -0
- asap/testing/assertions.py +108 -0
- asap/testing/fixtures.py +113 -0
- asap/testing/mocks.py +152 -0
- asap/transport/__init__.py +31 -0
- asap/transport/cache.py +180 -0
- asap/transport/circuit_breaker.py +194 -0
- asap/transport/client.py +989 -72
- asap/transport/compression.py +389 -0
- asap/transport/handlers.py +106 -53
- asap/transport/middleware.py +64 -39
- asap/transport/server.py +461 -94
- asap/transport/validators.py +320 -0
- asap/utils/__init__.py +7 -0
- asap/utils/sanitization.py +134 -0
- asap_protocol-1.0.0.dist-info/METADATA +264 -0
- asap_protocol-1.0.0.dist-info/RECORD +70 -0
- asap_protocol-0.3.0.dist-info/METADATA +0 -227
- asap_protocol-0.3.0.dist-info/RECORD +0 -37
- {asap_protocol-0.3.0.dist-info → asap_protocol-1.0.0.dist-info}/WHEEL +0 -0
- {asap_protocol-0.3.0.dist-info → asap_protocol-1.0.0.dist-info}/entry_points.txt +0 -0
- {asap_protocol-0.3.0.dist-info → asap_protocol-1.0.0.dist-info}/licenses/LICENSE +0 -0
asap/transport/middleware.py
CHANGED
|
@@ -32,9 +32,10 @@ Example:
|
|
|
32
32
|
>>> middleware = AuthenticationMiddleware(manifest, validator)
|
|
33
33
|
"""
|
|
34
34
|
|
|
35
|
-
import
|
|
35
|
+
import asyncio
|
|
36
|
+
import inspect
|
|
36
37
|
import uuid
|
|
37
|
-
from typing import Any, Awaitable, Callable, Protocol
|
|
38
|
+
from typing import Any, Awaitable, Callable, Protocol, cast
|
|
38
39
|
from collections.abc import Sequence
|
|
39
40
|
|
|
40
41
|
from fastapi import HTTPException, Request
|
|
@@ -47,6 +48,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
|
|
47
48
|
|
|
48
49
|
from asap.models.entities import Manifest
|
|
49
50
|
from asap.observability import get_logger
|
|
51
|
+
from asap.utils.sanitization import sanitize_token
|
|
50
52
|
|
|
51
53
|
logger = get_logger(__name__)
|
|
52
54
|
|
|
@@ -54,7 +56,9 @@ logger = get_logger(__name__)
|
|
|
54
56
|
AUTH_SCHEME_BEARER = "bearer"
|
|
55
57
|
|
|
56
58
|
# Rate limiting default configuration
|
|
57
|
-
|
|
59
|
+
# Uses token bucket pattern: burst limit (per second) + sustained limit (per minute)
|
|
60
|
+
# This allows short bursts while preventing sustained abuse
|
|
61
|
+
DEFAULT_RATE_LIMIT = "10/second;100/minute"
|
|
58
62
|
|
|
59
63
|
|
|
60
64
|
def _get_sender_from_envelope(request: Request) -> str:
|
|
@@ -80,9 +84,7 @@ def _get_sender_from_envelope(request: Request) -> str:
|
|
|
80
84
|
>>> sender = _get_sender_from_envelope(request)
|
|
81
85
|
>>> # Returns "192.168.1.1" (IP address, not sender URN)
|
|
82
86
|
"""
|
|
83
|
-
# Try to extract sender from envelope if already parsed (early returns reduce complexity)
|
|
84
87
|
try:
|
|
85
|
-
# Check if envelope is stored in request state (after parsing)
|
|
86
88
|
if hasattr(request.state, "envelope") and request.state.envelope:
|
|
87
89
|
envelope = request.state.envelope
|
|
88
90
|
if hasattr(envelope, "sender") and isinstance(envelope.sender, str):
|
|
@@ -105,17 +107,12 @@ def _get_sender_from_envelope(request: Request) -> str:
|
|
|
105
107
|
# Envelope not available, fall back to IP
|
|
106
108
|
pass
|
|
107
109
|
|
|
108
|
-
# Fallback to client IP address
|
|
109
110
|
remote_addr = get_remote_address(request)
|
|
110
|
-
# Type narrowing: get_remote_address returns str, but mypy may see it as Any
|
|
111
111
|
if isinstance(remote_addr, str):
|
|
112
112
|
return remote_addr
|
|
113
113
|
return str(remote_addr)
|
|
114
114
|
|
|
115
115
|
|
|
116
|
-
# Create rate limiter instance with IP-based key function
|
|
117
|
-
# Note: The key function attempts to extract sender but always falls back to IP
|
|
118
|
-
# because rate limiting executes before request body parsing
|
|
119
116
|
limiter = Limiter(
|
|
120
117
|
key_func=_get_sender_from_envelope,
|
|
121
118
|
default_limits=[DEFAULT_RATE_LIMIT],
|
|
@@ -154,8 +151,15 @@ def create_test_limiter(limits: Sequence[str] | None = None) -> Limiter:
|
|
|
154
151
|
def create_limiter(limits: Sequence[str] | None = None) -> Limiter:
|
|
155
152
|
"""Create a new limiter instance for production use.
|
|
156
153
|
|
|
157
|
-
Creates an isolated limiter instance with its own storage
|
|
158
|
-
multiple FastAPI app instances to have
|
|
154
|
+
Creates an isolated limiter instance with its own in-memory storage
|
|
155
|
+
(``memory://``), allowing multiple FastAPI app instances to have
|
|
156
|
+
independent rate limiters.
|
|
157
|
+
|
|
158
|
+
**Multi-worker warning:** ``memory://`` storage is per-process. In
|
|
159
|
+
multi-worker deployments (e.g., Gunicorn with 4 workers), each worker
|
|
160
|
+
has isolated limits—effective rate = configured limit × number of workers
|
|
161
|
+
(e.g., 10/s → 40/s across workers). For shared limits in production,
|
|
162
|
+
use Redis-backed storage via slowapi's ``storage_uri``.
|
|
159
163
|
|
|
160
164
|
Args:
|
|
161
165
|
limits: Optional list of rate limit strings (e.g., ["100/minute"]).
|
|
@@ -173,10 +177,16 @@ def create_limiter(limits: Sequence[str] | None = None) -> Limiter:
|
|
|
173
177
|
|
|
174
178
|
# Use unique storage URI to ensure isolation between app instances
|
|
175
179
|
unique_storage_id = str(uuid.uuid4())
|
|
180
|
+
storage_uri = f"memory://{unique_storage_id}"
|
|
181
|
+
logger.warning(
|
|
182
|
+
"asap.rate_limit.memory_storage",
|
|
183
|
+
message="memory:// storage is per-process; in multi-worker deployments "
|
|
184
|
+
"(e.g., Gunicorn), effective rate = limit × workers. Use Redis for shared limits.",
|
|
185
|
+
)
|
|
176
186
|
return Limiter(
|
|
177
187
|
key_func=_get_sender_from_envelope,
|
|
178
188
|
default_limits=list(limits),
|
|
179
|
-
storage_uri=
|
|
189
|
+
storage_uri=storage_uri,
|
|
180
190
|
)
|
|
181
191
|
|
|
182
192
|
|
|
@@ -197,7 +207,6 @@ def rate_limit_handler(request: Request, exc: Exception) -> JSONResponse:
|
|
|
197
207
|
>>> response = rate_limit_handler(request, exc)
|
|
198
208
|
>>> # Returns JSONResponse with status_code=429 and JSON-RPC error
|
|
199
209
|
"""
|
|
200
|
-
# Type narrowing: FastAPI passes RateLimitExceeded but handler signature uses Exception
|
|
201
210
|
if not isinstance(exc, RateLimitExceeded):
|
|
202
211
|
# Fallback for unexpected exception types
|
|
203
212
|
logger.warning("asap.rate_limit.unexpected_exception", exc_type=type(exc).__name__)
|
|
@@ -259,7 +268,8 @@ HTTP_TOO_MANY_REQUESTS = 429
|
|
|
259
268
|
|
|
260
269
|
# Error messages
|
|
261
270
|
ERROR_AUTH_REQUIRED = "Authentication required"
|
|
262
|
-
|
|
271
|
+
# nosec B105: This is an error message constant, not a hardcoded password
|
|
272
|
+
ERROR_INVALID_TOKEN = "Invalid authentication token" # nosec B105
|
|
263
273
|
ERROR_SENDER_MISMATCH = "Sender does not match authenticated identity"
|
|
264
274
|
ERROR_RATE_LIMIT_EXCEEDED = "Rate limit exceeded"
|
|
265
275
|
|
|
@@ -268,25 +278,32 @@ class TokenValidator(Protocol):
|
|
|
268
278
|
"""Protocol for token validation implementations.
|
|
269
279
|
|
|
270
280
|
Custom validators must implement this interface to integrate
|
|
271
|
-
with the authentication middleware.
|
|
281
|
+
with the authentication middleware. Validators may be sync or async.
|
|
282
|
+
Sync validators that perform I/O (e.g., DB/Redis lookups) are run
|
|
283
|
+
in a thread pool to avoid blocking the event loop.
|
|
272
284
|
|
|
273
|
-
Example:
|
|
274
|
-
>>> class
|
|
285
|
+
Example (sync):
|
|
286
|
+
>>> class MySyncValidator:
|
|
275
287
|
... def __call__(self, token: str) -> str | None:
|
|
276
|
-
... # Validate token against database, JWT, etc.
|
|
277
288
|
... if is_valid(token):
|
|
278
289
|
... return extract_agent_id(token)
|
|
279
290
|
... return None
|
|
291
|
+
|
|
292
|
+
Example (async):
|
|
293
|
+
>>> class MyAsyncValidator:
|
|
294
|
+
... async def __call__(self, token: str) -> str | None:
|
|
295
|
+
... return await db.lookup_agent(token)
|
|
280
296
|
"""
|
|
281
297
|
|
|
282
|
-
def __call__(self, token: str) -> str | None:
|
|
298
|
+
def __call__(self, token: str) -> str | None | Awaitable[str | None]:
|
|
283
299
|
"""Validate a token and return the authenticated agent ID.
|
|
284
300
|
|
|
285
301
|
Args:
|
|
286
302
|
token: The authentication token to validate
|
|
287
303
|
|
|
288
304
|
Returns:
|
|
289
|
-
The agent ID (URN) if token is valid, None otherwise
|
|
305
|
+
The agent ID (URN) if token is valid, None otherwise.
|
|
306
|
+
May return a coroutine for async validators.
|
|
290
307
|
|
|
291
308
|
Example:
|
|
292
309
|
>>> validator = BearerTokenValidator(my_validate_func)
|
|
@@ -300,15 +317,14 @@ class BearerTokenValidator:
|
|
|
300
317
|
"""Default Bearer token validator implementation.
|
|
301
318
|
|
|
302
319
|
Wraps a validation function to conform to the TokenValidator protocol.
|
|
303
|
-
The validation function
|
|
304
|
-
|
|
320
|
+
The validation function may be sync or async. Sync validators that perform
|
|
321
|
+
I/O are run in a thread pool by the middleware to avoid blocking the loop.
|
|
305
322
|
|
|
306
323
|
Attributes:
|
|
307
324
|
validate_func: Function that validates tokens and returns agent IDs
|
|
308
325
|
|
|
309
326
|
Example:
|
|
310
327
|
>>> def my_validator(token: str) -> str | None:
|
|
311
|
-
... # Check token in database
|
|
312
328
|
... if token in valid_tokens:
|
|
313
329
|
... return valid_tokens[token]["agent_id"]
|
|
314
330
|
... return None
|
|
@@ -317,22 +333,28 @@ class BearerTokenValidator:
|
|
|
317
333
|
>>> agent_id = validator("abc123")
|
|
318
334
|
"""
|
|
319
335
|
|
|
320
|
-
def __init__(
|
|
336
|
+
def __init__(
|
|
337
|
+
self,
|
|
338
|
+
validate_func: Callable[[str], str | None | Awaitable[str | None]],
|
|
339
|
+
) -> None:
|
|
321
340
|
"""Initialize the Bearer token validator.
|
|
322
341
|
|
|
323
342
|
Args:
|
|
324
|
-
validate_func:
|
|
343
|
+
validate_func: Sync or async function that validates tokens
|
|
344
|
+
and returns agent IDs. Sync functions with I/O are run
|
|
345
|
+
in a thread pool to avoid blocking the event loop.
|
|
325
346
|
"""
|
|
326
347
|
self.validate_func = validate_func
|
|
327
348
|
|
|
328
|
-
def __call__(self, token: str) -> str | None:
|
|
349
|
+
def __call__(self, token: str) -> str | None | Awaitable[str | None]:
|
|
329
350
|
"""Validate a token and return the authenticated agent ID.
|
|
330
351
|
|
|
331
352
|
Args:
|
|
332
353
|
token: The authentication token to validate
|
|
333
354
|
|
|
334
355
|
Returns:
|
|
335
|
-
The agent ID (URN) if token is valid, None otherwise
|
|
356
|
+
The agent ID (URN) if token is valid, None otherwise.
|
|
357
|
+
May return a coroutine for async validate_func.
|
|
336
358
|
"""
|
|
337
359
|
return self.validate_func(token)
|
|
338
360
|
|
|
@@ -387,7 +409,6 @@ class AuthenticationMiddleware:
|
|
|
387
409
|
self.validator = validator
|
|
388
410
|
self.security = HTTPBearer(auto_error=False)
|
|
389
411
|
|
|
390
|
-
# Validate configuration
|
|
391
412
|
if self._is_auth_required() and validator is None:
|
|
392
413
|
raise ValueError(
|
|
393
414
|
"Token validator required when authentication is configured in manifest"
|
|
@@ -460,7 +481,6 @@ class AuthenticationMiddleware:
|
|
|
460
481
|
headers={"WWW-Authenticate": "Bearer"},
|
|
461
482
|
)
|
|
462
483
|
|
|
463
|
-
# Validate Authorization scheme (case-insensitive)
|
|
464
484
|
if credentials.scheme.lower() != AUTH_SCHEME_BEARER:
|
|
465
485
|
logger.warning(
|
|
466
486
|
"asap.auth.invalid_scheme",
|
|
@@ -474,7 +494,6 @@ class AuthenticationMiddleware:
|
|
|
474
494
|
headers={"WWW-Authenticate": "Bearer"},
|
|
475
495
|
)
|
|
476
496
|
|
|
477
|
-
# Validate Bearer token support in manifest
|
|
478
497
|
if not self._supports_bearer_auth():
|
|
479
498
|
logger.warning(
|
|
480
499
|
"asap.auth.scheme_not_supported",
|
|
@@ -487,23 +506,31 @@ class AuthenticationMiddleware:
|
|
|
487
506
|
headers={"WWW-Authenticate": "Bearer"},
|
|
488
507
|
)
|
|
489
508
|
|
|
490
|
-
# Validate token and get agent ID
|
|
491
509
|
token = credentials.credentials
|
|
492
|
-
# Type narrowing: validator is not None when auth is required (validated in __init__)
|
|
493
510
|
if self.validator is None:
|
|
494
511
|
raise RuntimeError(
|
|
495
512
|
"Token validator is None but authentication is required. "
|
|
496
513
|
"This should not happen if middleware was initialized correctly."
|
|
497
514
|
)
|
|
498
|
-
|
|
515
|
+
if inspect.iscoroutinefunction(self.validator.__call__):
|
|
516
|
+
result = self.validator(token)
|
|
517
|
+
agent_id = await cast(Awaitable[str | None], result)
|
|
518
|
+
else:
|
|
519
|
+
# Cast: to_thread expects Callable[..., T]; TokenValidator may return Awaitable
|
|
520
|
+
# but we handle that below (BearerTokenValidator wrapping async func)
|
|
521
|
+
agent_id = await asyncio.to_thread(
|
|
522
|
+
cast("Callable[[str], str | None]", self.validator), token
|
|
523
|
+
)
|
|
524
|
+
if inspect.isawaitable(agent_id):
|
|
525
|
+
agent_id = await cast(Awaitable[str | None], agent_id)
|
|
499
526
|
|
|
500
527
|
if agent_id is None:
|
|
501
|
-
# Log token
|
|
502
|
-
|
|
528
|
+
# Log sanitized token to avoid exposing full token data
|
|
529
|
+
token_prefix = sanitize_token(token)
|
|
503
530
|
logger.warning(
|
|
504
531
|
"asap.auth.invalid_token",
|
|
505
532
|
manifest_id=self.manifest.id,
|
|
506
|
-
|
|
533
|
+
token_prefix=token_prefix,
|
|
507
534
|
)
|
|
508
535
|
raise HTTPException(
|
|
509
536
|
status_code=HTTP_UNAUTHORIZED,
|
|
@@ -614,7 +641,6 @@ class SizeLimitMiddleware(BaseHTTPMiddleware):
|
|
|
614
641
|
Returns:
|
|
615
642
|
Response from next handler or error response if size exceeded
|
|
616
643
|
"""
|
|
617
|
-
# Check Content-Length header if present
|
|
618
644
|
content_length = request.headers.get("content-length")
|
|
619
645
|
if content_length:
|
|
620
646
|
try:
|
|
@@ -633,7 +659,6 @@ class SizeLimitMiddleware(BaseHTTPMiddleware):
|
|
|
633
659
|
},
|
|
634
660
|
)
|
|
635
661
|
except ValueError:
|
|
636
|
-
# Invalid Content-Length header, let route handler validate actual body size
|
|
637
662
|
pass
|
|
638
663
|
|
|
639
664
|
# Continue to next middleware or route handler
|