asap-protocol 0.5.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/examples/README.md +81 -13
- 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 +0 -2
- 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/base.py +0 -3
- asap/models/constants.py +3 -1
- asap/models/entities.py +21 -6
- asap/models/envelope.py +7 -0
- 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 +28 -0
- asap/transport/cache.py +180 -0
- asap/transport/circuit_breaker.py +9 -8
- asap/transport/client.py +418 -36
- asap/transport/compression.py +389 -0
- asap/transport/handlers.py +106 -53
- asap/transport/middleware.py +58 -34
- asap/transport/server.py +429 -139
- asap/transport/validators.py +0 -4
- asap/utils/sanitization.py +0 -5
- asap_protocol-1.0.0.dist-info/METADATA +264 -0
- asap_protocol-1.0.0.dist-info/RECORD +70 -0
- asap_protocol-0.5.0.dist-info/METADATA +0 -244
- asap_protocol-0.5.0.dist-info/RECORD +0 -41
- {asap_protocol-0.5.0.dist-info → asap_protocol-1.0.0.dist-info}/WHEEL +0 -0
- {asap_protocol-0.5.0.dist-info → asap_protocol-1.0.0.dist-info}/entry_points.txt +0 -0
- {asap_protocol-0.5.0.dist-info → asap_protocol-1.0.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Custom assertions for ASAP protocol tests.
|
|
2
|
+
|
|
3
|
+
This module provides assertion helpers to validate envelopes and
|
|
4
|
+
task outcomes with clear error messages.
|
|
5
|
+
|
|
6
|
+
Functions:
|
|
7
|
+
assert_envelope_valid: Assert an Envelope has required fields and valid shape.
|
|
8
|
+
assert_task_completed: Assert a TaskResponse or envelope payload indicates
|
|
9
|
+
task completion (e.g. status completed).
|
|
10
|
+
assert_response_correlates: Assert response envelope correlates to request.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from asap.models.envelope import Envelope
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def assert_envelope_valid(
|
|
19
|
+
envelope: Envelope,
|
|
20
|
+
*,
|
|
21
|
+
require_id: bool = True,
|
|
22
|
+
require_timestamp: bool = True,
|
|
23
|
+
allowed_payload_types: list[str] | None = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Assert that an envelope has required fields and valid structure.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
envelope: The envelope to validate.
|
|
29
|
+
require_id: If True, envelope.id must be non-empty.
|
|
30
|
+
require_timestamp: If True, envelope.timestamp must be set.
|
|
31
|
+
allowed_payload_types: If set, payload_type must be in this list.
|
|
32
|
+
|
|
33
|
+
Raises:
|
|
34
|
+
AssertionError: If any check fails.
|
|
35
|
+
"""
|
|
36
|
+
assert envelope is not None, "Envelope must not be None" # nosec B101
|
|
37
|
+
if require_id:
|
|
38
|
+
assert envelope.id, "Envelope must have a non-empty id" # nosec B101
|
|
39
|
+
if require_timestamp:
|
|
40
|
+
assert envelope.timestamp is not None, "Envelope must have a timestamp" # nosec B101
|
|
41
|
+
assert envelope.sender, "Envelope must have a sender" # nosec B101
|
|
42
|
+
assert envelope.recipient, "Envelope must have a recipient" # nosec B101
|
|
43
|
+
assert envelope.payload_type, "Envelope must have a payload_type" # nosec B101
|
|
44
|
+
assert envelope.payload is not None, "Envelope must have a payload" # nosec B101
|
|
45
|
+
if allowed_payload_types is not None:
|
|
46
|
+
assert envelope.payload_type in allowed_payload_types, ( # nosec B101
|
|
47
|
+
f"payload_type {envelope.payload_type!r} not in {allowed_payload_types}"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def assert_task_completed(
|
|
52
|
+
payload: dict[str, Any] | Envelope,
|
|
53
|
+
*,
|
|
54
|
+
status_key: str = "status",
|
|
55
|
+
completed_value: str = "completed",
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Assert that a task response indicates completion.
|
|
58
|
+
|
|
59
|
+
Accepts either a TaskResponse-like dict (with status_key) or an
|
|
60
|
+
Envelope whose payload is such a dict.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
payload: TaskResponse payload dict or Envelope containing it.
|
|
64
|
+
status_key: Key in payload that holds status (default 'status').
|
|
65
|
+
completed_value: Value that indicates completion (default 'completed').
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
AssertionError: If payload does not indicate completion.
|
|
69
|
+
"""
|
|
70
|
+
if isinstance(payload, Envelope):
|
|
71
|
+
payload = payload.payload or {}
|
|
72
|
+
assert isinstance(payload, dict), "payload must be a dict or Envelope" # nosec B101
|
|
73
|
+
actual = payload.get(status_key)
|
|
74
|
+
assert actual == completed_value, f"Expected task status {completed_value!r}, got {actual!r}" # nosec B101
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def assert_response_correlates(
|
|
78
|
+
request_envelope: Envelope,
|
|
79
|
+
response_envelope: Envelope,
|
|
80
|
+
*,
|
|
81
|
+
correlation_id_field: str = "correlation_id",
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Assert that a response envelope correlates to the request (by correlation id).
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
request_envelope: The request envelope (must have id).
|
|
87
|
+
response_envelope: The response envelope (must have correlation_id).
|
|
88
|
+
correlation_id_field: Attribute name on response for correlation (default
|
|
89
|
+
'correlation_id').
|
|
90
|
+
|
|
91
|
+
Raises:
|
|
92
|
+
AssertionError: If request id or response correlation_id is missing or
|
|
93
|
+
they do not match.
|
|
94
|
+
"""
|
|
95
|
+
assert request_envelope.id, "Request envelope must have a non-empty id" # nosec B101
|
|
96
|
+
correlation_id = getattr(response_envelope, correlation_id_field, None)
|
|
97
|
+
assert correlation_id is not None, f"Response envelope must have {correlation_id_field!r}" # nosec B101
|
|
98
|
+
assert correlation_id == request_envelope.id, ( # nosec B101
|
|
99
|
+
f"Response {correlation_id_field!r} {correlation_id!r} does not match "
|
|
100
|
+
f"request id {request_envelope.id!r}"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
__all__ = [
|
|
105
|
+
"assert_envelope_valid",
|
|
106
|
+
"assert_task_completed",
|
|
107
|
+
"assert_response_correlates",
|
|
108
|
+
]
|
asap/testing/fixtures.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Pytest fixtures and context managers for ASAP tests.
|
|
2
|
+
|
|
3
|
+
This module provides shared fixtures and context managers to reduce
|
|
4
|
+
boilerplate when testing agents, clients, and snapshot stores.
|
|
5
|
+
|
|
6
|
+
Fixtures (use with pytest):
|
|
7
|
+
mock_agent: Configurable mock agent for request/response tests.
|
|
8
|
+
mock_client: ASAP client configured for testing (async; use in async tests).
|
|
9
|
+
mock_snapshot_store: In-memory snapshot store for state persistence tests.
|
|
10
|
+
|
|
11
|
+
Context managers:
|
|
12
|
+
test_agent(): Sync context manager yielding a MockAgent for the scope.
|
|
13
|
+
test_client(): Async context manager yielding an ASAPClient for the scope.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from contextlib import asynccontextmanager, contextmanager
|
|
17
|
+
from typing import AsyncIterator, Iterator
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
|
|
21
|
+
from asap.state.snapshot import InMemorySnapshotStore
|
|
22
|
+
from asap.testing.mocks import MockAgent
|
|
23
|
+
from asap.transport.client import ASAPClient
|
|
24
|
+
|
|
25
|
+
DEFAULT_TEST_BASE_URL = "http://localhost:9999"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.fixture
|
|
29
|
+
def mock_agent() -> MockAgent:
|
|
30
|
+
"""Create a fresh MockAgent for the test.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
A MockAgent instance (cleared between tests via fresh fixture).
|
|
34
|
+
"""
|
|
35
|
+
return MockAgent()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.fixture
|
|
39
|
+
def mock_snapshot_store() -> InMemorySnapshotStore:
|
|
40
|
+
"""Create an in-memory snapshot store for the test.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
An InMemorySnapshotStore instance (empty, isolated per test).
|
|
44
|
+
"""
|
|
45
|
+
return InMemorySnapshotStore()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.fixture
|
|
49
|
+
async def mock_client() -> AsyncIterator[ASAPClient]:
|
|
50
|
+
"""Provide an ASAPClient entered for the test (async fixture).
|
|
51
|
+
|
|
52
|
+
Yields an open ASAPClient pointing at DEFAULT_TEST_BASE_URL.
|
|
53
|
+
Use in async tests; the client is closed after the test.
|
|
54
|
+
|
|
55
|
+
Yields:
|
|
56
|
+
ASAPClient instance (already in async context).
|
|
57
|
+
"""
|
|
58
|
+
async with ASAPClient(DEFAULT_TEST_BASE_URL) as client:
|
|
59
|
+
yield client
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@contextmanager
|
|
63
|
+
def test_agent(agent_id: str = "urn:asap:agent:mock") -> Iterator[MockAgent]:
|
|
64
|
+
"""Context manager that provides a MockAgent for the scope.
|
|
65
|
+
|
|
66
|
+
On exit, the agent is cleared (requests and responses reset).
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
agent_id: URN for the mock agent.
|
|
70
|
+
|
|
71
|
+
Yields:
|
|
72
|
+
A MockAgent instance.
|
|
73
|
+
|
|
74
|
+
Example:
|
|
75
|
+
>>> with test_agent() as agent:
|
|
76
|
+
... agent.set_response("echo", {"status": "completed"})
|
|
77
|
+
... out = agent.handle(request_envelope)
|
|
78
|
+
"""
|
|
79
|
+
agent = MockAgent(agent_id)
|
|
80
|
+
try:
|
|
81
|
+
yield agent
|
|
82
|
+
finally:
|
|
83
|
+
agent.clear()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@asynccontextmanager
|
|
87
|
+
async def test_client(
|
|
88
|
+
base_url: str = DEFAULT_TEST_BASE_URL,
|
|
89
|
+
) -> AsyncIterator[ASAPClient]:
|
|
90
|
+
"""Async context manager that provides an ASAPClient for the scope.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
base_url: Agent base URL (default: localhost:9999 for test servers).
|
|
94
|
+
|
|
95
|
+
Yields:
|
|
96
|
+
An open ASAPClient instance.
|
|
97
|
+
|
|
98
|
+
Example:
|
|
99
|
+
>>> async with test_client("http://localhost:8000") as client:
|
|
100
|
+
... response = await client.send(envelope)
|
|
101
|
+
"""
|
|
102
|
+
async with ASAPClient(base_url) as client:
|
|
103
|
+
yield client
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
__all__ = [
|
|
107
|
+
"DEFAULT_TEST_BASE_URL",
|
|
108
|
+
"mock_agent",
|
|
109
|
+
"mock_client",
|
|
110
|
+
"mock_snapshot_store",
|
|
111
|
+
"test_agent",
|
|
112
|
+
"test_client",
|
|
113
|
+
]
|
asap/testing/mocks.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Mock agents and request/response recording for ASAP tests.
|
|
2
|
+
|
|
3
|
+
This module provides MockAgent: a configurable mock agent that can
|
|
4
|
+
pre-set responses and record incoming requests for assertions.
|
|
5
|
+
|
|
6
|
+
Features:
|
|
7
|
+
- Pre-set responses per skill or per envelope pattern.
|
|
8
|
+
- Request recording (incoming envelopes) for later assertion.
|
|
9
|
+
- Configurable delay or failure for error-path tests.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import time
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from asap.models.envelope import Envelope
|
|
17
|
+
from asap.models.ids import generate_id
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MockAgent:
|
|
21
|
+
"""Configurable mock agent for testing ASAP integrations.
|
|
22
|
+
|
|
23
|
+
Simulates an agent without a real server. Supports pre-set responses
|
|
24
|
+
per skill, request recording, optional delay, and simulated failures
|
|
25
|
+
for error-path tests.
|
|
26
|
+
|
|
27
|
+
Attributes:
|
|
28
|
+
agent_id: URN identifying this mock agent (e.g. urn:asap:agent:test-echo).
|
|
29
|
+
requests: List of all envelopes received by handle() (read-only).
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, agent_id: str = "urn:asap:agent:mock") -> None:
|
|
33
|
+
"""Initialize the mock agent.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
agent_id: URN for this mock agent.
|
|
37
|
+
"""
|
|
38
|
+
self.agent_id = agent_id
|
|
39
|
+
self._responses: dict[str, dict[str, Any]] = {}
|
|
40
|
+
self._default_response: dict[str, Any] | None = None
|
|
41
|
+
self._delay_seconds: float = 0.0
|
|
42
|
+
self._failure: BaseException | None = None
|
|
43
|
+
self.requests: list[Envelope] = []
|
|
44
|
+
|
|
45
|
+
def set_response(self, skill_id: str, payload: dict[str, Any]) -> None:
|
|
46
|
+
"""Pre-set the response payload for a skill.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
skill_id: Skill id (e.g. 'echo').
|
|
50
|
+
payload: Response payload (e.g. TaskResponse.model_dump()).
|
|
51
|
+
"""
|
|
52
|
+
self._responses[skill_id] = payload
|
|
53
|
+
|
|
54
|
+
def set_default_response(self, payload: dict[str, Any]) -> None:
|
|
55
|
+
"""Pre-set a response used when no skill-specific response is set.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
payload: Response payload (e.g. TaskResponse.model_dump()).
|
|
59
|
+
"""
|
|
60
|
+
self._default_response = payload
|
|
61
|
+
|
|
62
|
+
def set_delay(self, seconds: float) -> None:
|
|
63
|
+
"""Set a delay (sleep) before returning the response. Useful for timeout tests.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
seconds: Delay in seconds (0 to disable).
|
|
67
|
+
"""
|
|
68
|
+
self._delay_seconds = max(0.0, seconds)
|
|
69
|
+
|
|
70
|
+
def set_failure(self, exception: BaseException | None) -> None:
|
|
71
|
+
"""Set a failure to raise when handle() is called. Clears after raise.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
exception: Exception to raise (None to disable).
|
|
75
|
+
"""
|
|
76
|
+
self._failure = exception
|
|
77
|
+
|
|
78
|
+
def requests_for_skill(self, skill_id: str) -> list[Envelope]:
|
|
79
|
+
"""Return recorded requests whose payload has the given skill_id.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
skill_id: Skill id to filter by.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
List of envelopes that requested this skill.
|
|
86
|
+
"""
|
|
87
|
+
result: list[Envelope] = []
|
|
88
|
+
for env in self.requests:
|
|
89
|
+
payload = env.payload if isinstance(env.payload, dict) else {}
|
|
90
|
+
if payload.get("skill_id") == skill_id:
|
|
91
|
+
result.append(env)
|
|
92
|
+
return result
|
|
93
|
+
|
|
94
|
+
def handle(self, envelope: Envelope) -> Envelope | None:
|
|
95
|
+
"""Handle an incoming envelope and return a response envelope.
|
|
96
|
+
|
|
97
|
+
Records the request. Optionally sleeps (set_delay), raises (set_failure),
|
|
98
|
+
or returns an envelope built from the pre-set response for the
|
|
99
|
+
envelope's skill_id or default response.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
envelope: Incoming ASAP envelope.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Response envelope or None if no response configured.
|
|
106
|
+
|
|
107
|
+
Raises:
|
|
108
|
+
BaseException: If set_failure() was called with an exception.
|
|
109
|
+
"""
|
|
110
|
+
self.requests.append(envelope)
|
|
111
|
+
|
|
112
|
+
if self._failure is not None:
|
|
113
|
+
exc = self._failure
|
|
114
|
+
self._failure = None
|
|
115
|
+
raise exc
|
|
116
|
+
|
|
117
|
+
skill_id = (
|
|
118
|
+
(envelope.payload or {}).get("skill_id") if isinstance(envelope.payload, dict) else None
|
|
119
|
+
)
|
|
120
|
+
payload = (self._responses.get(skill_id) if skill_id else None) or self._default_response
|
|
121
|
+
if payload is None:
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
if self._delay_seconds > 0:
|
|
125
|
+
time.sleep(self._delay_seconds)
|
|
126
|
+
|
|
127
|
+
return Envelope(
|
|
128
|
+
id=generate_id(),
|
|
129
|
+
asap_version=envelope.asap_version,
|
|
130
|
+
timestamp=datetime.now(timezone.utc),
|
|
131
|
+
sender=self.agent_id,
|
|
132
|
+
recipient=envelope.sender,
|
|
133
|
+
payload_type="TaskResponse",
|
|
134
|
+
payload=payload,
|
|
135
|
+
correlation_id=envelope.id,
|
|
136
|
+
trace_id=envelope.trace_id,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def clear(self) -> None:
|
|
140
|
+
"""Clear recorded requests, pre-set responses, delay, and failure."""
|
|
141
|
+
self.requests.clear()
|
|
142
|
+
self._responses.clear()
|
|
143
|
+
self._default_response = None
|
|
144
|
+
self._delay_seconds = 0.0
|
|
145
|
+
self._failure = None
|
|
146
|
+
|
|
147
|
+
def reset(self) -> None:
|
|
148
|
+
"""Reset internal state. Equivalent to clear()."""
|
|
149
|
+
self.clear()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
__all__ = ["MockAgent"]
|
asap/transport/__init__.py
CHANGED
|
@@ -41,6 +41,11 @@ Example:
|
|
|
41
41
|
>>> app = create_app(manifest)
|
|
42
42
|
"""
|
|
43
43
|
|
|
44
|
+
from asap.transport.cache import (
|
|
45
|
+
DEFAULT_MAX_SIZE,
|
|
46
|
+
DEFAULT_TTL,
|
|
47
|
+
ManifestCache,
|
|
48
|
+
)
|
|
44
49
|
from asap.transport.client import (
|
|
45
50
|
ASAPClient,
|
|
46
51
|
ASAPConnectionError,
|
|
@@ -48,6 +53,16 @@ from asap.transport.client import (
|
|
|
48
53
|
ASAPTimeoutError,
|
|
49
54
|
RetryConfig,
|
|
50
55
|
)
|
|
56
|
+
from asap.transport.compression import (
|
|
57
|
+
COMPRESSION_THRESHOLD,
|
|
58
|
+
CompressionAlgorithm,
|
|
59
|
+
compress_payload,
|
|
60
|
+
decompress_payload,
|
|
61
|
+
get_accept_encoding_header,
|
|
62
|
+
get_supported_encodings,
|
|
63
|
+
is_brotli_available,
|
|
64
|
+
select_best_encoding,
|
|
65
|
+
)
|
|
51
66
|
from asap.transport.handlers import (
|
|
52
67
|
Handler,
|
|
53
68
|
HandlerNotFoundError,
|
|
@@ -84,4 +99,17 @@ __all__ = [
|
|
|
84
99
|
"ASAPTimeoutError",
|
|
85
100
|
"ASAPRemoteError",
|
|
86
101
|
"RetryConfig",
|
|
102
|
+
# Cache
|
|
103
|
+
"ManifestCache",
|
|
104
|
+
"DEFAULT_TTL",
|
|
105
|
+
"DEFAULT_MAX_SIZE",
|
|
106
|
+
# Compression
|
|
107
|
+
"COMPRESSION_THRESHOLD",
|
|
108
|
+
"CompressionAlgorithm",
|
|
109
|
+
"compress_payload",
|
|
110
|
+
"decompress_payload",
|
|
111
|
+
"get_accept_encoding_header",
|
|
112
|
+
"get_supported_encodings",
|
|
113
|
+
"is_brotli_available",
|
|
114
|
+
"select_best_encoding",
|
|
87
115
|
]
|
asap/transport/cache.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Manifest caching for ASAP protocol.
|
|
2
|
+
|
|
3
|
+
This module provides caching for agent manifests to reduce HTTP requests
|
|
4
|
+
and improve performance when discovering agent capabilities.
|
|
5
|
+
|
|
6
|
+
The cache uses an OrderedDict with TTL (Time To Live) expiration and LRU
|
|
7
|
+
eviction when max_size is reached. Entries expire after the configured
|
|
8
|
+
TTL (default: 5 minutes).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
from collections import OrderedDict
|
|
13
|
+
from threading import Lock
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
from asap.models.entities import Manifest
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Default TTL in seconds (5 minutes)
|
|
20
|
+
DEFAULT_TTL = 300.0
|
|
21
|
+
|
|
22
|
+
# Default max cache size (number of entries)
|
|
23
|
+
DEFAULT_MAX_SIZE = 1000
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CacheEntry:
|
|
27
|
+
"""Cache entry with TTL expiration.
|
|
28
|
+
|
|
29
|
+
Attributes:
|
|
30
|
+
manifest: Cached manifest object
|
|
31
|
+
expires_at: Timestamp when entry expires (seconds since epoch)
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, manifest: Manifest, ttl: float) -> None:
|
|
35
|
+
"""Initialize cache entry.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
manifest: Manifest to cache
|
|
39
|
+
ttl: Time to live in seconds
|
|
40
|
+
"""
|
|
41
|
+
self.manifest = manifest
|
|
42
|
+
self.expires_at = time.time() + ttl
|
|
43
|
+
|
|
44
|
+
def is_expired(self) -> bool:
|
|
45
|
+
"""Check if cache entry has expired.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
True if entry has expired, False otherwise
|
|
49
|
+
"""
|
|
50
|
+
return time.time() >= self.expires_at
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ManifestCache:
|
|
54
|
+
"""Thread-safe in-memory LRU cache for agent manifests.
|
|
55
|
+
|
|
56
|
+
Provides TTL-based expiration, LRU eviction when max_size is reached,
|
|
57
|
+
and methods for cache management. Thread-safe for concurrent access
|
|
58
|
+
from multiple async tasks.
|
|
59
|
+
|
|
60
|
+
Attributes:
|
|
61
|
+
_cache: OrderedDict mapping URL to CacheEntry (maintains LRU order)
|
|
62
|
+
_lock: Lock for thread-safe access
|
|
63
|
+
_default_ttl: Default TTL in seconds
|
|
64
|
+
_max_size: Maximum number of entries (0 for unlimited)
|
|
65
|
+
|
|
66
|
+
Example:
|
|
67
|
+
>>> cache = ManifestCache(default_ttl=300.0, max_size=100)
|
|
68
|
+
>>> cache.set("http://agent.example.com/manifest.json", manifest, ttl=300.0)
|
|
69
|
+
>>> cached = cache.get("http://agent.example.com/manifest.json")
|
|
70
|
+
>>> if cached:
|
|
71
|
+
... print(cached.id)
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
default_ttl: float = DEFAULT_TTL,
|
|
77
|
+
max_size: int = DEFAULT_MAX_SIZE,
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Initialize manifest cache.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
default_ttl: Default TTL in seconds for cache entries (default: 300.0)
|
|
83
|
+
max_size: Maximum number of cache entries. When exceeded, the least
|
|
84
|
+
recently used entry is evicted. Set to 0 for unlimited size.
|
|
85
|
+
Default: 1000. Very large values may increase cleanup_expired() latency.
|
|
86
|
+
"""
|
|
87
|
+
self._cache: OrderedDict[str, CacheEntry] = OrderedDict()
|
|
88
|
+
self._lock = Lock()
|
|
89
|
+
self._default_ttl = default_ttl
|
|
90
|
+
self._max_size = max_size
|
|
91
|
+
|
|
92
|
+
def get(self, url: str) -> Optional[Manifest]:
|
|
93
|
+
"""Get manifest from cache if present and not expired.
|
|
94
|
+
|
|
95
|
+
Accessing an entry moves it to the end of the LRU queue,
|
|
96
|
+
marking it as most recently used.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
url: Manifest URL
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Cached manifest if found and not expired, None otherwise
|
|
103
|
+
"""
|
|
104
|
+
with self._lock:
|
|
105
|
+
entry = self._cache.get(url)
|
|
106
|
+
if entry is None:
|
|
107
|
+
return None
|
|
108
|
+
if entry.is_expired():
|
|
109
|
+
del self._cache[url]
|
|
110
|
+
return None
|
|
111
|
+
self._cache.move_to_end(url)
|
|
112
|
+
return entry.manifest
|
|
113
|
+
|
|
114
|
+
def set(self, url: str, manifest: Manifest, ttl: Optional[float] = None) -> None:
|
|
115
|
+
"""Store manifest in cache with TTL.
|
|
116
|
+
|
|
117
|
+
If max_size is set and the cache is full, the least recently used
|
|
118
|
+
entry is evicted before adding the new entry.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
url: Manifest URL (cache key)
|
|
122
|
+
manifest: Manifest to cache
|
|
123
|
+
ttl: Time to live in seconds (defaults to default_ttl)
|
|
124
|
+
"""
|
|
125
|
+
if ttl is None:
|
|
126
|
+
ttl = self._default_ttl
|
|
127
|
+
with self._lock:
|
|
128
|
+
if url in self._cache:
|
|
129
|
+
del self._cache[url]
|
|
130
|
+
elif self._max_size > 0:
|
|
131
|
+
while len(self._cache) >= self._max_size:
|
|
132
|
+
self._cache.popitem(last=False)
|
|
133
|
+
self._cache[url] = CacheEntry(manifest, ttl)
|
|
134
|
+
|
|
135
|
+
def invalidate(self, url: str) -> None:
|
|
136
|
+
"""Remove manifest from cache.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
url: Manifest URL to invalidate
|
|
140
|
+
"""
|
|
141
|
+
with self._lock:
|
|
142
|
+
self._cache.pop(url, None)
|
|
143
|
+
|
|
144
|
+
def clear_all(self) -> None:
|
|
145
|
+
"""Clear all cached manifests."""
|
|
146
|
+
with self._lock:
|
|
147
|
+
self._cache.clear()
|
|
148
|
+
|
|
149
|
+
def size(self) -> int:
|
|
150
|
+
"""Get number of cached entries (including expired).
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Number of entries in cache
|
|
154
|
+
"""
|
|
155
|
+
with self._lock:
|
|
156
|
+
return len(self._cache)
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def max_size(self) -> int:
|
|
160
|
+
"""Get the configured maximum cache size.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
Maximum number of entries (0 for unlimited)
|
|
164
|
+
"""
|
|
165
|
+
return self._max_size
|
|
166
|
+
|
|
167
|
+
def cleanup_expired(self) -> int:
|
|
168
|
+
"""Remove all expired entries from cache.
|
|
169
|
+
|
|
170
|
+
Holds the lock for O(N). For very large max_size, prefer lazy eviction
|
|
171
|
+
in get() or call less frequently.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
Number of expired entries removed
|
|
175
|
+
"""
|
|
176
|
+
with self._lock:
|
|
177
|
+
expired_urls = [url for url, entry in self._cache.items() if entry.is_expired()]
|
|
178
|
+
for url in expired_urls:
|
|
179
|
+
del self._cache[url]
|
|
180
|
+
return len(expired_urls)
|
|
@@ -7,7 +7,6 @@ for sharing circuit breaker state across multiple client instances.
|
|
|
7
7
|
import threading
|
|
8
8
|
import time
|
|
9
9
|
from enum import Enum
|
|
10
|
-
from typing import Dict
|
|
11
10
|
|
|
12
11
|
from asap.models.constants import (
|
|
13
12
|
DEFAULT_CIRCUIT_BREAKER_THRESHOLD,
|
|
@@ -78,13 +77,18 @@ class CircuitBreaker:
|
|
|
78
77
|
def record_failure(self) -> None:
|
|
79
78
|
"""Record a failed request.
|
|
80
79
|
|
|
81
|
-
Increments failure count and opens circuit if threshold is reached
|
|
80
|
+
Increments failure count and opens circuit if threshold is reached
|
|
81
|
+
or if the circuit was in HALF_OPEN state.
|
|
82
82
|
"""
|
|
83
83
|
with self._lock:
|
|
84
84
|
self._consecutive_failures += 1
|
|
85
85
|
self._last_failure_time = time.time()
|
|
86
86
|
|
|
87
|
-
|
|
87
|
+
threshold_reached = (
|
|
88
|
+
self._consecutive_failures >= self.threshold and self._state == CircuitState.CLOSED
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if self._state == CircuitState.HALF_OPEN or threshold_reached:
|
|
88
92
|
self._state = CircuitState.OPEN
|
|
89
93
|
|
|
90
94
|
def can_attempt(self) -> bool:
|
|
@@ -94,18 +98,14 @@ class CircuitBreaker:
|
|
|
94
98
|
True if request can be attempted, False if circuit is open
|
|
95
99
|
"""
|
|
96
100
|
with self._lock:
|
|
97
|
-
# Check if we should transition from OPEN to HALF_OPEN
|
|
98
101
|
if self._state == CircuitState.OPEN:
|
|
99
102
|
if self._last_failure_time is not None:
|
|
100
103
|
elapsed = time.time() - self._last_failure_time
|
|
101
104
|
if elapsed >= self.timeout:
|
|
102
|
-
# Transition to HALF_OPEN to test recovery
|
|
103
105
|
self._state = CircuitState.HALF_OPEN
|
|
104
106
|
return True
|
|
105
|
-
# Still in OPEN state, reject request
|
|
106
107
|
return False
|
|
107
108
|
|
|
108
|
-
# CLOSED or HALF_OPEN: allow request
|
|
109
109
|
return True
|
|
110
110
|
|
|
111
111
|
def get_state(self) -> CircuitState:
|
|
@@ -136,7 +136,7 @@ class CircuitBreakerRegistry:
|
|
|
136
136
|
|
|
137
137
|
def __init__(self) -> None:
|
|
138
138
|
"""Initialize registry."""
|
|
139
|
-
self._breakers:
|
|
139
|
+
self._breakers: dict[str, CircuitBreaker] = {}
|
|
140
140
|
self._lock = threading.RLock()
|
|
141
141
|
|
|
142
142
|
def get_or_create(
|
|
@@ -176,6 +176,7 @@ class CircuitBreakerRegistry:
|
|
|
176
176
|
# Global registry instance
|
|
177
177
|
# In a more complex app, this might be injected, but a module-level singleton
|
|
178
178
|
# is standard for this pattern in Python clients.
|
|
179
|
+
# WARNING: This state persists across tests. Use get_registry().clear() in tearDown.
|
|
179
180
|
_registry = CircuitBreakerRegistry()
|
|
180
181
|
|
|
181
182
|
|