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/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
|
@@ -18,6 +18,7 @@ Public exports:
|
|
|
18
18
|
create_echo_handler: Factory for echo handler
|
|
19
19
|
create_default_registry: Factory for default registry
|
|
20
20
|
ASAPClient: Async HTTP client for agent communication
|
|
21
|
+
RetryConfig: Configuration dataclass for retry logic and circuit breaker
|
|
21
22
|
ASAPConnectionError: Connection error exception
|
|
22
23
|
ASAPTimeoutError: Timeout error exception
|
|
23
24
|
ASAPRemoteError: Remote error exception
|
|
@@ -40,11 +41,27 @@ Example:
|
|
|
40
41
|
>>> app = create_app(manifest)
|
|
41
42
|
"""
|
|
42
43
|
|
|
44
|
+
from asap.transport.cache import (
|
|
45
|
+
DEFAULT_MAX_SIZE,
|
|
46
|
+
DEFAULT_TTL,
|
|
47
|
+
ManifestCache,
|
|
48
|
+
)
|
|
43
49
|
from asap.transport.client import (
|
|
44
50
|
ASAPClient,
|
|
45
51
|
ASAPConnectionError,
|
|
46
52
|
ASAPRemoteError,
|
|
47
53
|
ASAPTimeoutError,
|
|
54
|
+
RetryConfig,
|
|
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,
|
|
48
65
|
)
|
|
49
66
|
from asap.transport.handlers import (
|
|
50
67
|
Handler,
|
|
@@ -81,4 +98,18 @@ __all__ = [
|
|
|
81
98
|
"ASAPConnectionError",
|
|
82
99
|
"ASAPTimeoutError",
|
|
83
100
|
"ASAPRemoteError",
|
|
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",
|
|
84
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)
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Circuit breaker implementation for resilient request handling.
|
|
2
|
+
|
|
3
|
+
This module provides the CircuitBreaker pattern implementation and a registry
|
|
4
|
+
for sharing circuit breaker state across multiple client instances.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from enum import Enum
|
|
10
|
+
|
|
11
|
+
from asap.models.constants import (
|
|
12
|
+
DEFAULT_CIRCUIT_BREAKER_THRESHOLD,
|
|
13
|
+
DEFAULT_CIRCUIT_BREAKER_TIMEOUT,
|
|
14
|
+
)
|
|
15
|
+
from asap.observability import get_logger
|
|
16
|
+
|
|
17
|
+
logger = get_logger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CircuitState(str, Enum):
|
|
21
|
+
"""Circuit breaker states.
|
|
22
|
+
|
|
23
|
+
CLOSED: Normal operation, requests are allowed
|
|
24
|
+
OPEN: Circuit is open, requests are rejected immediately
|
|
25
|
+
HALF_OPEN: Testing state, allows one request to test if service recovered
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
CLOSED = "closed"
|
|
29
|
+
OPEN = "open"
|
|
30
|
+
HALF_OPEN = "half_open"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CircuitBreaker:
|
|
34
|
+
"""Circuit breaker pattern implementation for resilient request handling.
|
|
35
|
+
|
|
36
|
+
The circuit breaker prevents cascading failures by opening the circuit
|
|
37
|
+
after a threshold of consecutive failures, then attempting to recover
|
|
38
|
+
after a timeout period.
|
|
39
|
+
|
|
40
|
+
States:
|
|
41
|
+
- CLOSED: Normal operation, all requests allowed
|
|
42
|
+
- OPEN: Circuit is open, all requests rejected immediately
|
|
43
|
+
- HALF_OPEN: Testing state, allows one request to test recovery
|
|
44
|
+
|
|
45
|
+
This implementation is thread-safe using RLock for concurrent access.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
threshold: int = DEFAULT_CIRCUIT_BREAKER_THRESHOLD,
|
|
51
|
+
timeout: float = DEFAULT_CIRCUIT_BREAKER_TIMEOUT,
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Initialize circuit breaker.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
threshold: Number of consecutive failures before opening (default: 5)
|
|
57
|
+
timeout: Seconds before transitioning OPEN -> HALF_OPEN (default: 60.0)
|
|
58
|
+
"""
|
|
59
|
+
self.threshold = threshold
|
|
60
|
+
self.timeout = timeout
|
|
61
|
+
self._state = CircuitState.CLOSED
|
|
62
|
+
self._consecutive_failures = 0
|
|
63
|
+
self._last_failure_time: float | None = None
|
|
64
|
+
self._lock = threading.RLock()
|
|
65
|
+
|
|
66
|
+
def record_success(self) -> None:
|
|
67
|
+
"""Record a successful request.
|
|
68
|
+
|
|
69
|
+
Resets failure count and closes circuit if it was HALF_OPEN.
|
|
70
|
+
"""
|
|
71
|
+
with self._lock:
|
|
72
|
+
self._consecutive_failures = 0
|
|
73
|
+
if self._state == CircuitState.HALF_OPEN:
|
|
74
|
+
self._state = CircuitState.CLOSED
|
|
75
|
+
self._last_failure_time = None
|
|
76
|
+
|
|
77
|
+
def record_failure(self) -> None:
|
|
78
|
+
"""Record a failed request.
|
|
79
|
+
|
|
80
|
+
Increments failure count and opens circuit if threshold is reached
|
|
81
|
+
or if the circuit was in HALF_OPEN state.
|
|
82
|
+
"""
|
|
83
|
+
with self._lock:
|
|
84
|
+
self._consecutive_failures += 1
|
|
85
|
+
self._last_failure_time = time.time()
|
|
86
|
+
|
|
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:
|
|
92
|
+
self._state = CircuitState.OPEN
|
|
93
|
+
|
|
94
|
+
def can_attempt(self) -> bool:
|
|
95
|
+
"""Check if a request can be attempted.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
True if request can be attempted, False if circuit is open
|
|
99
|
+
"""
|
|
100
|
+
with self._lock:
|
|
101
|
+
if self._state == CircuitState.OPEN:
|
|
102
|
+
if self._last_failure_time is not None:
|
|
103
|
+
elapsed = time.time() - self._last_failure_time
|
|
104
|
+
if elapsed >= self.timeout:
|
|
105
|
+
self._state = CircuitState.HALF_OPEN
|
|
106
|
+
return True
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
return True
|
|
110
|
+
|
|
111
|
+
def get_state(self) -> CircuitState:
|
|
112
|
+
"""Get current circuit state.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Current circuit state
|
|
116
|
+
"""
|
|
117
|
+
with self._lock:
|
|
118
|
+
return self._state
|
|
119
|
+
|
|
120
|
+
def get_consecutive_failures(self) -> int:
|
|
121
|
+
"""Get number of consecutive failures.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
Number of consecutive failures
|
|
125
|
+
"""
|
|
126
|
+
with self._lock:
|
|
127
|
+
return self._consecutive_failures
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class CircuitBreakerRegistry:
|
|
131
|
+
"""Registry for managing shared CircuitBreaker instances.
|
|
132
|
+
|
|
133
|
+
Ensures that multiple clients connecting to the same implementation
|
|
134
|
+
share the same circuit breaker state.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
def __init__(self) -> None:
|
|
138
|
+
"""Initialize registry."""
|
|
139
|
+
self._breakers: dict[str, CircuitBreaker] = {}
|
|
140
|
+
self._lock = threading.RLock()
|
|
141
|
+
|
|
142
|
+
def get_or_create(
|
|
143
|
+
self,
|
|
144
|
+
base_url: str,
|
|
145
|
+
threshold: int = DEFAULT_CIRCUIT_BREAKER_THRESHOLD,
|
|
146
|
+
timeout: float = DEFAULT_CIRCUIT_BREAKER_TIMEOUT,
|
|
147
|
+
) -> CircuitBreaker:
|
|
148
|
+
"""Get existing circuit breaker or create a new one.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
base_url: The target URL (key for the registry)
|
|
152
|
+
threshold: Threshold for new breakers (ignored if exists)
|
|
153
|
+
timeout: Timeout for new breakers (ignored if exists)
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
Shared CircuitBreaker instance
|
|
157
|
+
"""
|
|
158
|
+
with self._lock:
|
|
159
|
+
if base_url not in self._breakers:
|
|
160
|
+
logger.info(
|
|
161
|
+
"asap.circuit_breaker.created",
|
|
162
|
+
base_url=base_url,
|
|
163
|
+
threshold=threshold,
|
|
164
|
+
timeout=timeout,
|
|
165
|
+
message=f"Created shared circuit breaker for {base_url}",
|
|
166
|
+
)
|
|
167
|
+
self._breakers[base_url] = CircuitBreaker(threshold=threshold, timeout=timeout)
|
|
168
|
+
return self._breakers[base_url]
|
|
169
|
+
|
|
170
|
+
def clear(self) -> None:
|
|
171
|
+
"""Clear all registered circuit breakers (mostly for testing)."""
|
|
172
|
+
with self._lock:
|
|
173
|
+
self._breakers.clear()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# Global registry instance
|
|
177
|
+
# In a more complex app, this might be injected, but a module-level singleton
|
|
178
|
+
# is standard for this pattern in Python clients.
|
|
179
|
+
# WARNING: This state persists across tests. Use get_registry().clear() in tearDown.
|
|
180
|
+
_registry = CircuitBreakerRegistry()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def get_circuit_breaker(
|
|
184
|
+
base_url: str,
|
|
185
|
+
threshold: int = DEFAULT_CIRCUIT_BREAKER_THRESHOLD,
|
|
186
|
+
timeout: float = DEFAULT_CIRCUIT_BREAKER_TIMEOUT,
|
|
187
|
+
) -> CircuitBreaker:
|
|
188
|
+
"""Helper to get a circuit breaker from the global registry."""
|
|
189
|
+
return _registry.get_or_create(base_url, threshold, timeout)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def get_registry() -> CircuitBreakerRegistry:
|
|
193
|
+
"""Helper to get the global registry instance."""
|
|
194
|
+
return _registry
|