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.
Files changed (59) hide show
  1. asap/__init__.py +1 -1
  2. asap/cli.py +137 -2
  3. asap/examples/README.md +81 -13
  4. asap/examples/auth_patterns.py +212 -0
  5. asap/examples/error_recovery.py +248 -0
  6. asap/examples/long_running.py +287 -0
  7. asap/examples/mcp_integration.py +240 -0
  8. asap/examples/multi_step_workflow.py +134 -0
  9. asap/examples/orchestration.py +293 -0
  10. asap/examples/rate_limiting.py +137 -0
  11. asap/examples/run_demo.py +0 -2
  12. asap/examples/secure_handler.py +84 -0
  13. asap/examples/state_migration.py +240 -0
  14. asap/examples/streaming_response.py +108 -0
  15. asap/examples/websocket_concept.py +129 -0
  16. asap/mcp/__init__.py +43 -0
  17. asap/mcp/client.py +224 -0
  18. asap/mcp/protocol.py +179 -0
  19. asap/mcp/server.py +333 -0
  20. asap/mcp/server_runner.py +40 -0
  21. asap/models/base.py +0 -3
  22. asap/models/constants.py +3 -1
  23. asap/models/entities.py +21 -6
  24. asap/models/envelope.py +7 -0
  25. asap/models/ids.py +8 -4
  26. asap/models/parts.py +33 -3
  27. asap/models/validators.py +16 -0
  28. asap/observability/__init__.py +6 -0
  29. asap/observability/dashboards/README.md +24 -0
  30. asap/observability/dashboards/asap-detailed.json +131 -0
  31. asap/observability/dashboards/asap-red.json +129 -0
  32. asap/observability/logging.py +81 -1
  33. asap/observability/metrics.py +15 -1
  34. asap/observability/trace_parser.py +238 -0
  35. asap/observability/trace_ui.py +218 -0
  36. asap/observability/tracing.py +293 -0
  37. asap/state/machine.py +15 -2
  38. asap/state/snapshot.py +0 -9
  39. asap/testing/__init__.py +31 -0
  40. asap/testing/assertions.py +108 -0
  41. asap/testing/fixtures.py +113 -0
  42. asap/testing/mocks.py +152 -0
  43. asap/transport/__init__.py +28 -0
  44. asap/transport/cache.py +180 -0
  45. asap/transport/circuit_breaker.py +9 -8
  46. asap/transport/client.py +418 -36
  47. asap/transport/compression.py +389 -0
  48. asap/transport/handlers.py +106 -53
  49. asap/transport/middleware.py +58 -34
  50. asap/transport/server.py +429 -139
  51. asap/transport/validators.py +0 -4
  52. asap/utils/sanitization.py +0 -5
  53. asap_protocol-1.0.0.dist-info/METADATA +264 -0
  54. asap_protocol-1.0.0.dist-info/RECORD +70 -0
  55. asap_protocol-0.5.0.dist-info/METADATA +0 -244
  56. asap_protocol-0.5.0.dist-info/RECORD +0 -41
  57. {asap_protocol-0.5.0.dist-info → asap_protocol-1.0.0.dist-info}/WHEEL +0 -0
  58. {asap_protocol-0.5.0.dist-info → asap_protocol-1.0.0.dist-info}/entry_points.txt +0 -0
  59. {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
+ ]
@@ -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"]
@@ -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
  ]
@@ -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
- if self._consecutive_failures >= self.threshold and self._state == CircuitState.CLOSED:
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: Dict[str, CircuitBreaker] = {}
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