resilient-circuit 0.4.1__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.
- resilient_circuit/__init__.py +16 -0
- resilient_circuit/backoff.py +37 -0
- resilient_circuit/buffer.py +55 -0
- resilient_circuit/circuit_breaker.py +311 -0
- resilient_circuit/cli.py +261 -0
- resilient_circuit/exceptions.py +10 -0
- resilient_circuit/failsafe.py +34 -0
- resilient_circuit/policy.py +13 -0
- resilient_circuit/py.typed +0 -0
- resilient_circuit/retry.py +47 -0
- resilient_circuit/storage.py +252 -0
- resilient_circuit-0.4.1.dist-info/METADATA +443 -0
- resilient_circuit-0.4.1.dist-info/RECORD +17 -0
- resilient_circuit-0.4.1.dist-info/WHEEL +5 -0
- resilient_circuit-0.4.1.dist-info/entry_points.txt +2 -0
- resilient_circuit-0.4.1.dist-info/licenses/LICENSE +201 -0
- resilient_circuit-0.4.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from typing import Callable, Sequence, TypeVar
|
|
2
|
+
|
|
3
|
+
from typing_extensions import ParamSpec
|
|
4
|
+
|
|
5
|
+
from resilient_circuit.policy import ProtectionPolicy
|
|
6
|
+
|
|
7
|
+
R = TypeVar("R")
|
|
8
|
+
P = ParamSpec("P")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SafetyNet:
|
|
12
|
+
"""Decorates function with given policies.
|
|
13
|
+
|
|
14
|
+
SafetyNet will handle execution results in reverse, with last policy applied first.
|
|
15
|
+
|
|
16
|
+
Example:
|
|
17
|
+
>>> from resilient_circuit import SafetyNet, RetryWithBackoffPolicy, CircuitProtectorPolicy
|
|
18
|
+
>>>
|
|
19
|
+
>>> @SafetyNet(policies=(RetryWithBackoffPolicy(), CircuitProtectorPolicy()))
|
|
20
|
+
>>> def some_method() -> bool:
|
|
21
|
+
>>> return True
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, *, policies: Sequence[ProtectionPolicy]) -> None:
|
|
25
|
+
if len(policies) != len(set(policies)):
|
|
26
|
+
raise ValueError("All policies must be unique.")
|
|
27
|
+
|
|
28
|
+
self.policies = policies
|
|
29
|
+
|
|
30
|
+
def __call__(self, func: Callable[P, R]) -> Callable[P, R]:
|
|
31
|
+
"""Decorate func with all policies in reversed order."""
|
|
32
|
+
for policy in reversed(self.policies):
|
|
33
|
+
func = policy(func)
|
|
34
|
+
return func
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
from typing import Callable, TypeVar
|
|
3
|
+
|
|
4
|
+
from typing_extensions import ParamSpec
|
|
5
|
+
|
|
6
|
+
R = TypeVar("R")
|
|
7
|
+
P = ParamSpec("P")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ProtectionPolicy(abc.ABC):
|
|
11
|
+
@abc.abstractmethod
|
|
12
|
+
def __call__(self, func: Callable[P, R]) -> Callable[P, R]:
|
|
13
|
+
"""Apply policy to callable."""
|
|
File without changes
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from functools import wraps
|
|
2
|
+
from time import sleep
|
|
3
|
+
from typing import Callable, Optional, TypeVar
|
|
4
|
+
|
|
5
|
+
from typing_extensions import ParamSpec
|
|
6
|
+
|
|
7
|
+
from resilient_circuit.backoff import ExponentialDelay
|
|
8
|
+
from resilient_circuit.exceptions import ProtectedCallError, RetryLimitReached
|
|
9
|
+
from resilient_circuit.policy import ProtectionPolicy
|
|
10
|
+
|
|
11
|
+
R = TypeVar("R")
|
|
12
|
+
P = ParamSpec("P")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RetryWithBackoffPolicy(ProtectionPolicy):
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
*,
|
|
19
|
+
backoff: Optional[ExponentialDelay] = None,
|
|
20
|
+
max_retries: int = 3,
|
|
21
|
+
should_handle: Callable[[Exception], bool] = lambda e: True,
|
|
22
|
+
):
|
|
23
|
+
self.max_attempts = max_retries + 1
|
|
24
|
+
self.backoff = backoff
|
|
25
|
+
self.should_consider_failure = should_handle
|
|
26
|
+
|
|
27
|
+
def __call__(self, func: Callable[P, R]) -> Callable[P, R]:
|
|
28
|
+
@wraps(func)
|
|
29
|
+
def decorated(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
30
|
+
attempt = 0
|
|
31
|
+
last_exception = None
|
|
32
|
+
|
|
33
|
+
while attempt < self.max_attempts:
|
|
34
|
+
try:
|
|
35
|
+
return func(*args, **kwargs)
|
|
36
|
+
except Exception as e: # pylint: disable=broad-except
|
|
37
|
+
if not self.should_consider_failure(e):
|
|
38
|
+
raise
|
|
39
|
+
last_exception = e
|
|
40
|
+
|
|
41
|
+
attempt += 1
|
|
42
|
+
if self.backoff:
|
|
43
|
+
sleep(self.backoff.for_attempt(attempt))
|
|
44
|
+
|
|
45
|
+
raise RetryLimitReached from last_exception
|
|
46
|
+
|
|
47
|
+
return decorated
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Optional, Dict, Any
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
from dotenv import load_dotenv
|
|
9
|
+
load_dotenv()
|
|
10
|
+
HAS_DOTENV = True
|
|
11
|
+
except ImportError:
|
|
12
|
+
HAS_DOTENV = False
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
import psycopg
|
|
16
|
+
from psycopg import Connection
|
|
17
|
+
HAS_PSYCOPG = True
|
|
18
|
+
except ImportError:
|
|
19
|
+
HAS_PSYCOPG = False
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CircuitBreakerStorage(ABC):
|
|
25
|
+
"""Abstract base class for circuit breaker storage backends."""
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def get_state(self, resource_key: str) -> Optional[Dict[str, Any]]:
|
|
29
|
+
"""Get the state for a given resource key.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Dictionary with keys: state, failure_count, open_until
|
|
33
|
+
or None if no state found
|
|
34
|
+
"""
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def set_state(self, resource_key: str, state: str, failure_count: int, open_until: float) -> None:
|
|
39
|
+
"""Set the state for a given resource key."""
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class InMemoryStorage(CircuitBreakerStorage):
|
|
44
|
+
"""In-memory storage implementation for circuit breaker state."""
|
|
45
|
+
|
|
46
|
+
def __init__(self) -> None:
|
|
47
|
+
self._states: Dict[str, Dict[str, Any]] = {}
|
|
48
|
+
|
|
49
|
+
def get_state(self, resource_key: str) -> Optional[Dict[str, Any]]:
|
|
50
|
+
return self._states.get(resource_key)
|
|
51
|
+
|
|
52
|
+
def set_state(self, resource_key: str, state: str, failure_count: int, open_until: float) -> None:
|
|
53
|
+
self._states[resource_key] = {
|
|
54
|
+
"state": state,
|
|
55
|
+
"failure_count": failure_count,
|
|
56
|
+
"open_until": open_until
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class PostgresStorage(CircuitBreakerStorage):
|
|
61
|
+
"""PostgreSQL storage implementation for circuit breaker state."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, connection_string: str, namespace: str = "default"):
|
|
64
|
+
if not HAS_PSYCOPG:
|
|
65
|
+
raise ImportError("psycopg3 is required for PostgreSQL storage. Install with: pip install psycopg[binary]")
|
|
66
|
+
|
|
67
|
+
self.connection_string = connection_string
|
|
68
|
+
self.namespace = namespace
|
|
69
|
+
self._ensure_table_exists()
|
|
70
|
+
|
|
71
|
+
def _get_connection(self) -> Connection:
|
|
72
|
+
"""Get a database connection."""
|
|
73
|
+
return psycopg.connect(self.connection_string)
|
|
74
|
+
|
|
75
|
+
def _ensure_table_exists(self) -> None:
|
|
76
|
+
"""Ensure the circuit breaker table exists with namespace support."""
|
|
77
|
+
try:
|
|
78
|
+
with self._get_connection() as conn:
|
|
79
|
+
with conn.cursor() as cur:
|
|
80
|
+
# Check if namespace column exists
|
|
81
|
+
cur.execute("""
|
|
82
|
+
SELECT column_name
|
|
83
|
+
FROM information_schema.columns
|
|
84
|
+
WHERE table_name = 'rc_circuit_breakers'
|
|
85
|
+
AND column_name = 'namespace'
|
|
86
|
+
""")
|
|
87
|
+
has_namespace = cur.fetchone() is not None
|
|
88
|
+
|
|
89
|
+
if not has_namespace:
|
|
90
|
+
# Old schema without namespace - need to migrate
|
|
91
|
+
cur.execute("""
|
|
92
|
+
CREATE TABLE IF NOT EXISTS rc_circuit_breakers (
|
|
93
|
+
resource_key VARCHAR(255) NOT NULL,
|
|
94
|
+
state VARCHAR(50) NOT NULL,
|
|
95
|
+
failure_count INTEGER NOT NULL DEFAULT 0,
|
|
96
|
+
open_until TIMESTAMP,
|
|
97
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
98
|
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
99
|
+
namespace VARCHAR(255) NOT NULL DEFAULT 'default',
|
|
100
|
+
PRIMARY KEY (resource_key, namespace)
|
|
101
|
+
)
|
|
102
|
+
""")
|
|
103
|
+
|
|
104
|
+
# Add namespace column to existing table if it exists
|
|
105
|
+
cur.execute("""
|
|
106
|
+
DO $$
|
|
107
|
+
BEGIN
|
|
108
|
+
IF EXISTS (SELECT 1 FROM information_schema.tables
|
|
109
|
+
WHERE table_name = 'rc_circuit_breakers') THEN
|
|
110
|
+
ALTER TABLE rc_circuit_breakers
|
|
111
|
+
DROP CONSTRAINT IF EXISTS rc_circuit_breakers_pkey;
|
|
112
|
+
|
|
113
|
+
ALTER TABLE rc_circuit_breakers
|
|
114
|
+
ADD COLUMN IF NOT EXISTS namespace VARCHAR(255) NOT NULL DEFAULT 'default';
|
|
115
|
+
|
|
116
|
+
ALTER TABLE rc_circuit_breakers
|
|
117
|
+
ADD PRIMARY KEY (resource_key, namespace);
|
|
118
|
+
END IF;
|
|
119
|
+
END $$;
|
|
120
|
+
""")
|
|
121
|
+
else:
|
|
122
|
+
# Table exists with namespace column
|
|
123
|
+
cur.execute("""
|
|
124
|
+
CREATE TABLE IF NOT EXISTS rc_circuit_breakers (
|
|
125
|
+
resource_key VARCHAR(255) NOT NULL,
|
|
126
|
+
state VARCHAR(50) NOT NULL,
|
|
127
|
+
failure_count INTEGER NOT NULL DEFAULT 0,
|
|
128
|
+
open_until TIMESTAMP,
|
|
129
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
130
|
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
131
|
+
namespace VARCHAR(255) NOT NULL DEFAULT 'default',
|
|
132
|
+
PRIMARY KEY (resource_key, namespace)
|
|
133
|
+
)
|
|
134
|
+
""")
|
|
135
|
+
|
|
136
|
+
# Create indexes for better performance
|
|
137
|
+
cur.execute("""
|
|
138
|
+
CREATE INDEX IF NOT EXISTS idx_rc_circuit_breakers_state
|
|
139
|
+
ON rc_circuit_breakers(state)
|
|
140
|
+
""")
|
|
141
|
+
|
|
142
|
+
cur.execute("""
|
|
143
|
+
CREATE INDEX IF NOT EXISTS idx_rc_circuit_breakers_namespace
|
|
144
|
+
ON rc_circuit_breakers(namespace)
|
|
145
|
+
""")
|
|
146
|
+
|
|
147
|
+
cur.execute("""
|
|
148
|
+
CREATE INDEX IF NOT EXISTS idx_rc_circuit_breakers_key_namespace
|
|
149
|
+
ON rc_circuit_breakers(resource_key, namespace)
|
|
150
|
+
""")
|
|
151
|
+
|
|
152
|
+
conn.commit()
|
|
153
|
+
logger.info(f"PostgreSQL circuit breaker table ensured (namespace={self.namespace})")
|
|
154
|
+
except Exception as e:
|
|
155
|
+
logger.error(f"Failed to ensure table exists: {e}")
|
|
156
|
+
raise
|
|
157
|
+
|
|
158
|
+
def get_state(self, resource_key: str) -> Optional[Dict[str, Any]]:
|
|
159
|
+
"""Get the state for a given resource key within this namespace.
|
|
160
|
+
|
|
161
|
+
NOTE: This query uses FOR UPDATE to lock the row
|
|
162
|
+
to ensure this read-call-write cycle is atomic.
|
|
163
|
+
Namespace isolation ensures parallel tests don't conflict.
|
|
164
|
+
"""
|
|
165
|
+
try:
|
|
166
|
+
with self._get_connection() as conn:
|
|
167
|
+
with conn.cursor() as cur:
|
|
168
|
+
cur.execute(
|
|
169
|
+
"SELECT state, failure_count, open_until "
|
|
170
|
+
"FROM rc_circuit_breakers "
|
|
171
|
+
"WHERE resource_key = %s AND namespace = %s "
|
|
172
|
+
"FOR UPDATE",
|
|
173
|
+
(resource_key, self.namespace)
|
|
174
|
+
)
|
|
175
|
+
row = cur.fetchone()
|
|
176
|
+
if row:
|
|
177
|
+
return {
|
|
178
|
+
"state": row[0],
|
|
179
|
+
"failure_count": row[1],
|
|
180
|
+
"open_until": row[2].timestamp() if row[2] else 0
|
|
181
|
+
}
|
|
182
|
+
return None
|
|
183
|
+
except Exception as e:
|
|
184
|
+
logger.error(f"Failed to get state for {resource_key} (namespace={self.namespace}): {e}")
|
|
185
|
+
raise
|
|
186
|
+
|
|
187
|
+
def set_state(self, resource_key: str, state: str, failure_count: int, open_until: float) -> None:
|
|
188
|
+
"""Set the state for a given resource key within this namespace."""
|
|
189
|
+
try:
|
|
190
|
+
with self._get_connection() as conn:
|
|
191
|
+
with conn.cursor() as cur:
|
|
192
|
+
# Convert timestamp to PostgreSQL timestamp
|
|
193
|
+
open_until_ts = None
|
|
194
|
+
if open_until > 0:
|
|
195
|
+
open_until_ts = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(open_until))
|
|
196
|
+
|
|
197
|
+
cur.execute(
|
|
198
|
+
"""
|
|
199
|
+
INSERT INTO rc_circuit_breakers
|
|
200
|
+
(resource_key, namespace, state, failure_count, open_until)
|
|
201
|
+
VALUES (%s, %s, %s, %s, %s)
|
|
202
|
+
ON CONFLICT (resource_key, namespace) DO UPDATE SET
|
|
203
|
+
state = EXCLUDED.state,
|
|
204
|
+
failure_count = EXCLUDED.failure_count,
|
|
205
|
+
open_until = EXCLUDED.open_until,
|
|
206
|
+
updated_at = CURRENT_TIMESTAMP
|
|
207
|
+
""",
|
|
208
|
+
(resource_key, self.namespace, state, failure_count, open_until_ts)
|
|
209
|
+
)
|
|
210
|
+
conn.commit()
|
|
211
|
+
except Exception as e:
|
|
212
|
+
logger.error(f"Failed to set state for {resource_key} (namespace={self.namespace}): {e}")
|
|
213
|
+
raise
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def create_storage(namespace: Optional[str] = None) -> CircuitBreakerStorage:
|
|
217
|
+
"""Create the appropriate storage backend based on environment.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
namespace: Namespace for circuit breaker isolation. If None, uses environment
|
|
221
|
+
variable RC_NAMESPACE or defaults to "default".
|
|
222
|
+
Use different namespaces for test isolation (e.g., workflow_run_id).
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
CircuitBreakerStorage instance with namespace support
|
|
226
|
+
"""
|
|
227
|
+
# Get namespace from parameter or environment
|
|
228
|
+
if namespace is None:
|
|
229
|
+
namespace = os.getenv("RC_NAMESPACE", "default")
|
|
230
|
+
|
|
231
|
+
# Check for PostgreSQL connection info in environment
|
|
232
|
+
db_host = os.getenv("RC_DB_HOST")
|
|
233
|
+
db_port = os.getenv("RC_DB_PORT", "5432")
|
|
234
|
+
db_name = os.getenv("RC_DB_NAME", "resilient_circuit_db")
|
|
235
|
+
db_user = os.getenv("RC_DB_USER", "postgres")
|
|
236
|
+
db_password = os.getenv("RC_DB_PASSWORD")
|
|
237
|
+
|
|
238
|
+
if db_host and db_password:
|
|
239
|
+
# PostgreSQL storage requested
|
|
240
|
+
connection_string = f"host={db_host} port={db_port} dbname={db_name} user={db_user} password={db_password}"
|
|
241
|
+
try:
|
|
242
|
+
storage = PostgresStorage(connection_string, namespace=namespace)
|
|
243
|
+
logger.info(f"Using PostgreSQL storage for circuit breaker: host={db_host}, db={db_name}, namespace={namespace}")
|
|
244
|
+
return storage
|
|
245
|
+
except Exception as e:
|
|
246
|
+
logger.error(f"Failed to create PostgreSQL storage: {e}")
|
|
247
|
+
logger.warning("Falling back to in-memory storage")
|
|
248
|
+
return InMemoryStorage()
|
|
249
|
+
else:
|
|
250
|
+
# Default to in-memory storage
|
|
251
|
+
logger.info(f"Using in-memory storage for circuit breaker (no PostgreSQL config found), namespace={namespace}")
|
|
252
|
+
return InMemoryStorage()
|