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.
Files changed (62) hide show
  1. asap/__init__.py +1 -1
  2. asap/cli.py +137 -2
  3. asap/errors.py +167 -0
  4. asap/examples/README.md +81 -10
  5. asap/examples/auth_patterns.py +212 -0
  6. asap/examples/error_recovery.py +248 -0
  7. asap/examples/long_running.py +287 -0
  8. asap/examples/mcp_integration.py +240 -0
  9. asap/examples/multi_step_workflow.py +134 -0
  10. asap/examples/orchestration.py +293 -0
  11. asap/examples/rate_limiting.py +137 -0
  12. asap/examples/run_demo.py +9 -4
  13. asap/examples/secure_handler.py +84 -0
  14. asap/examples/state_migration.py +240 -0
  15. asap/examples/streaming_response.py +108 -0
  16. asap/examples/websocket_concept.py +129 -0
  17. asap/mcp/__init__.py +43 -0
  18. asap/mcp/client.py +224 -0
  19. asap/mcp/protocol.py +179 -0
  20. asap/mcp/server.py +333 -0
  21. asap/mcp/server_runner.py +40 -0
  22. asap/models/__init__.py +4 -0
  23. asap/models/base.py +0 -3
  24. asap/models/constants.py +76 -1
  25. asap/models/entities.py +58 -7
  26. asap/models/envelope.py +14 -1
  27. asap/models/ids.py +8 -4
  28. asap/models/parts.py +33 -3
  29. asap/models/validators.py +16 -0
  30. asap/observability/__init__.py +6 -0
  31. asap/observability/dashboards/README.md +24 -0
  32. asap/observability/dashboards/asap-detailed.json +131 -0
  33. asap/observability/dashboards/asap-red.json +129 -0
  34. asap/observability/logging.py +81 -1
  35. asap/observability/metrics.py +15 -1
  36. asap/observability/trace_parser.py +238 -0
  37. asap/observability/trace_ui.py +218 -0
  38. asap/observability/tracing.py +293 -0
  39. asap/state/machine.py +15 -2
  40. asap/state/snapshot.py +0 -9
  41. asap/testing/__init__.py +31 -0
  42. asap/testing/assertions.py +108 -0
  43. asap/testing/fixtures.py +113 -0
  44. asap/testing/mocks.py +152 -0
  45. asap/transport/__init__.py +31 -0
  46. asap/transport/cache.py +180 -0
  47. asap/transport/circuit_breaker.py +194 -0
  48. asap/transport/client.py +989 -72
  49. asap/transport/compression.py +389 -0
  50. asap/transport/handlers.py +106 -53
  51. asap/transport/middleware.py +64 -39
  52. asap/transport/server.py +461 -94
  53. asap/transport/validators.py +320 -0
  54. asap/utils/__init__.py +7 -0
  55. asap/utils/sanitization.py +134 -0
  56. asap_protocol-1.0.0.dist-info/METADATA +264 -0
  57. asap_protocol-1.0.0.dist-info/RECORD +70 -0
  58. asap_protocol-0.3.0.dist-info/METADATA +0 -227
  59. asap_protocol-0.3.0.dist-info/RECORD +0 -37
  60. {asap_protocol-0.3.0.dist-info → asap_protocol-1.0.0.dist-info}/WHEEL +0 -0
  61. {asap_protocol-0.3.0.dist-info → asap_protocol-1.0.0.dist-info}/entry_points.txt +0 -0
  62. {asap_protocol-0.3.0.dist-info → asap_protocol-1.0.0.dist-info}/licenses/LICENSE +0 -0
@@ -32,9 +32,10 @@ Example:
32
32
  >>> middleware = AuthenticationMiddleware(manifest, validator)
33
33
  """
34
34
 
35
- import hashlib
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
- DEFAULT_RATE_LIMIT = "100/minute"
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, allowing
158
- multiple FastAPI app instances to have independent rate limiters.
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=f"memory://{unique_storage_id}",
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
- ERROR_INVALID_TOKEN = "Invalid authentication token"
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 MyTokenValidator:
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 should take a token string and return an agent ID
304
- if valid, or None if invalid.
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__(self, validate_func: Callable[[str], str | None]) -> None:
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: Function that validates tokens and returns agent IDs
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
- agent_id = self.validator(token)
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 hash instead of prefix to avoid exposing token data
502
- token_hash = hashlib.sha256(token.encode()).hexdigest()[:16]
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
- token_hash=token_hash,
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