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.
@@ -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()