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,16 @@
1
+ from resilient_circuit.backoff import ExponentialDelay, FixedDelay
2
+ from resilient_circuit.circuit_breaker import CircuitProtectorPolicy
3
+ from resilient_circuit.circuit_breaker import CircuitStatus as CircuitState
4
+ from resilient_circuit.failsafe import SafetyNet
5
+ from resilient_circuit.policy import ProtectionPolicy
6
+ from resilient_circuit.retry import RetryWithBackoffPolicy
7
+
8
+ __all__ = (
9
+ "ExponentialDelay",
10
+ "CircuitProtectorPolicy",
11
+ "CircuitState",
12
+ "FixedDelay",
13
+ "SafetyNet",
14
+ "ProtectionPolicy",
15
+ "RetryWithBackoffPolicy",
16
+ )
@@ -0,0 +1,37 @@
1
+ import random
2
+ from dataclasses import dataclass
3
+ from datetime import timedelta
4
+ from typing import Optional
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class ExponentialDelay:
9
+ min_delay: timedelta
10
+ max_delay: timedelta
11
+ factor: int = 2
12
+ jitter: Optional[float] = None
13
+
14
+ def __post_init__(self) -> None:
15
+ if self.jitter is not None and (self.jitter < 0 or self.jitter > 1):
16
+ raise ValueError("`jitter` must be in range [0, 1].")
17
+
18
+ def for_attempt(self, attempt: int) -> float:
19
+ """Compute delay in seconds for a given attempt."""
20
+
21
+ if attempt < 1:
22
+ raise ValueError("`attempt` must be positive.")
23
+
24
+ delay = self.min_delay.total_seconds() * pow(self.factor, attempt - 1)
25
+
26
+ if self.jitter is not None:
27
+ offset = delay * self.jitter
28
+ delay += random.uniform(-offset, offset)
29
+
30
+ return min(delay, self.max_delay.total_seconds())
31
+
32
+
33
+ class FixedDelay(ExponentialDelay):
34
+ """Special case of ExponentialDelay when delay between calls is constant."""
35
+
36
+ def __init__(self, delay: timedelta) -> None:
37
+ super().__init__(min_delay=delay, max_delay=delay, factor=1)
@@ -0,0 +1,55 @@
1
+ from fractions import Fraction
2
+ from typing import Generic, List, TypeVar
3
+
4
+ T = TypeVar("T")
5
+
6
+
7
+ class GenericCircularBuffer(Generic[T]):
8
+ """Buffer that keeps last N items."""
9
+
10
+ def __init__(self, size: int) -> None:
11
+ if size < 1:
12
+ raise ValueError("`size` must be positive.")
13
+
14
+ self.size = size
15
+ self._items: List[T] = []
16
+
17
+ def __len__(self) -> int:
18
+ return len(self._items)
19
+
20
+ def __str__(self) -> str:
21
+ return str(self._items)
22
+
23
+ def __repr__(self) -> str:
24
+ return f"<{self.__class__.__name__}(size={self.size}): {self}>"
25
+
26
+ def add(self, item: T) -> None:
27
+ self._items.append(item)
28
+ self._items = self._items[-self.size :]
29
+
30
+ @property
31
+ def is_full(self) -> bool:
32
+ return len(self) >= self.size
33
+
34
+
35
+ class BinaryCircularBuffer(GenericCircularBuffer[bool]):
36
+ """GenericCircularBuffer of boolean items.
37
+
38
+ Introduces properties to get success/failures and their respective ratios.
39
+ """
40
+
41
+ @property
42
+ def success_count(self) -> int:
43
+ return self._items.count(True)
44
+
45
+ @property
46
+ def failure_count(self) -> int:
47
+ return self._items.count(False)
48
+
49
+ @property
50
+ def success_rate(self) -> Fraction:
51
+ return Fraction(self.success_count, len(self))
52
+
53
+ @property
54
+ def failure_rate(self) -> Fraction:
55
+ return Fraction(self.failure_count, len(self))
@@ -0,0 +1,311 @@
1
+ import abc
2
+ import enum
3
+ import logging
4
+ from datetime import datetime, timedelta
5
+ from fractions import Fraction
6
+ from functools import wraps
7
+ from typing import Callable, Optional, TypeVar
8
+
9
+ from typing_extensions import ParamSpec
10
+
11
+ from resilient_circuit.buffer import BinaryCircularBuffer
12
+ from resilient_circuit.exceptions import ProtectedCallError
13
+ from resilient_circuit.policy import ProtectionPolicy
14
+ from resilient_circuit.storage import CircuitBreakerStorage, InMemoryStorage, create_storage
15
+
16
+ R = TypeVar("R")
17
+ P = ParamSpec("P")
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class CircuitStatus(enum.Enum):
23
+ CLOSED = "CLOSED"
24
+ OPEN = "OPEN"
25
+ HALF_OPEN = "HALF_OPEN"
26
+
27
+
28
+ class CircuitProtectorPolicy(ProtectionPolicy):
29
+ DEFAULT_THRESHOLD = Fraction(1, 1)
30
+
31
+ def __init__(
32
+ self,
33
+ *,
34
+ resource_key: Optional[str] = None,
35
+ storage: Optional[CircuitBreakerStorage] = None,
36
+ namespace: Optional[str] = None,
37
+ cooldown: timedelta = timedelta(0),
38
+ failure_limit: Fraction = DEFAULT_THRESHOLD,
39
+ success_limit: Fraction = DEFAULT_THRESHOLD,
40
+ should_handle: Callable[[Exception], bool] = lambda e: True,
41
+ on_status_change: Optional[
42
+ Callable[["CircuitProtectorPolicy", CircuitStatus, CircuitStatus], None]
43
+ ] = None,
44
+ ) -> None:
45
+ # Generate a default resource key if not provided for backward compatibility
46
+ self.resource_key = resource_key or f"anonymous_{id(self)}"
47
+ # Create storage with namespace support if not provided
48
+ self.storage = storage or create_storage(namespace=namespace)
49
+ self.cooldown = cooldown
50
+ self.success_limit = success_limit
51
+ self.failure_limit = failure_limit
52
+ self.should_consider_failure = should_handle
53
+ self._on_status_change = on_status_change
54
+
55
+ # Load state from storage
56
+ self._load_state()
57
+
58
+ def _load_state(self) -> None:
59
+ """Load circuit breaker state from storage."""
60
+ try:
61
+ state_data = self.storage.get_state(self.resource_key)
62
+ if state_data:
63
+ # Restore state from storage
64
+ state = CircuitStatus(state_data["state"])
65
+ failure_count = int(state_data.get("failure_count", 0))
66
+ open_until = float(state_data.get("open_until", 0))
67
+
68
+ # Initialize status based on stored state
69
+ status: CircuitStatusBase
70
+ if state == CircuitStatus.CLOSED:
71
+ status = StatusClosed(policy=self, failure_count=failure_count)
72
+ elif state == CircuitStatus.OPEN:
73
+ # For OPEN state, we need a previous status to pass to the constructor
74
+ # We'll use a temporary closed status with same failure count
75
+ temp_status = StatusClosed(policy=self, failure_count=failure_count)
76
+ status = StatusOpen(policy=self, previous_status=temp_status, open_until=open_until)
77
+ else: # HALF_OPEN
78
+ status = StatusHalfOpen(policy=self, failure_count=failure_count)
79
+
80
+ self._status = status
81
+ logger.debug(f"Loaded circuit breaker state for {self.resource_key}: {state.value}")
82
+ else:
83
+ # No state found, start with CLOSED
84
+ self._status = StatusClosed(policy=self)
85
+ logger.debug(f"No stored state found for {self.resource_key}, starting with CLOSED")
86
+ except Exception as e:
87
+ logger.error(f"Failed to load state for {self.resource_key}: {e}")
88
+ # Fallback to default state
89
+ self._status = StatusClosed(policy=self)
90
+
91
+ def _save_state(self) -> None:
92
+ """Save circuit breaker state to storage."""
93
+ try:
94
+ state_value: str = self._status.status_type.value
95
+ failure_count_val: int = int(getattr(self._status, 'failure_count', 0))
96
+ # For StatusOpen, use open_until_timestamp; for others, default to 0
97
+ open_until_val: float = float(
98
+ getattr(self._status, 'open_until_timestamp', 0)
99
+ if hasattr(self._status, 'open_until_timestamp') else 0
100
+ )
101
+
102
+ self.storage.set_state(
103
+ self.resource_key,
104
+ state_value,
105
+ failure_count_val,
106
+ open_until_val
107
+ )
108
+ logger.debug(f"Saved circuit breaker state for {self.resource_key}: {state_value}")
109
+ except Exception as e:
110
+ logger.error(f"Failed to save state for {self.resource_key}: {e}")
111
+
112
+ @property
113
+ def execution_log(self) -> BinaryCircularBuffer:
114
+ return self._status.execution_log
115
+
116
+ @property
117
+ def status(self) -> CircuitStatus:
118
+ return self._status.status_type
119
+
120
+ @status.setter
121
+ def status(self, new_status: CircuitStatus) -> None:
122
+ old_status = self.status
123
+ new_status_obj: CircuitStatusBase
124
+ if new_status is CircuitStatus.CLOSED:
125
+ # When transitioning to CLOSED, reset failure count
126
+ new_status_obj = StatusClosed(policy=self, failure_count=0)
127
+ elif new_status is CircuitStatus.OPEN:
128
+ # When transitioning to OPEN, keep the failure count from current status
129
+ current_failure_count = getattr(self._status, 'failure_count', 0)
130
+ # Calculate the open_until timestamp based on current time and cooldown
131
+ from datetime import datetime
132
+ open_until = (datetime.now() + self.cooldown).timestamp()
133
+ new_status_obj = StatusOpen(policy=self, previous_status=self._status, open_until=open_until)
134
+ else: # HALF_OPEN
135
+ # When transitioning to HALF_OPEN, reset failure count
136
+ new_status_obj = StatusHalfOpen(policy=self, failure_count=0)
137
+
138
+ self._status = new_status_obj
139
+ self.on_status_change(old_status, new_status)
140
+ self._save_state() # Persist state change
141
+
142
+ def on_status_change(self, current: CircuitStatus, new: CircuitStatus) -> None:
143
+ """This method is called whenever protector changes its status."""
144
+ if self._on_status_change is not None:
145
+ self._on_status_change(self, current, new)
146
+
147
+ def __call__(self, func: Callable[P, R]) -> Callable[P, R]:
148
+ @wraps(func)
149
+ def decorated(*args: P.args, **kwargs: P.kwargs) -> R:
150
+ self._status.validate_execution()
151
+ try:
152
+ result = func(*args, **kwargs)
153
+ except Exception as e:
154
+ if self.should_consider_failure(e):
155
+ self._status.mark_failure()
156
+ else:
157
+ self._status.mark_success()
158
+ self._save_state() # Persist state after exception
159
+ raise
160
+ else:
161
+ self._status.mark_success()
162
+ self._save_state() # Persist state after success
163
+ return result
164
+
165
+ return decorated
166
+
167
+
168
+ class CircuitStatusBase(abc.ABC):
169
+ """Interface describing common methods of CircuitProtector's status."""
170
+
171
+ execution_log: BinaryCircularBuffer
172
+
173
+ def __init__(self, policy: CircuitProtectorPolicy):
174
+ self.policy = policy
175
+
176
+ @property
177
+ @abc.abstractmethod
178
+ def status_type(self) -> CircuitStatus:
179
+ """Defines type of the status."""
180
+
181
+ @abc.abstractmethod
182
+ def validate_execution(self) -> None:
183
+ """Override this method to raise an exception to prevent execution."""
184
+
185
+ @abc.abstractmethod
186
+ def mark_failure(self) -> None:
187
+ """This method is called whenever execution fails."""
188
+
189
+ @abc.abstractmethod
190
+ def mark_success(self) -> None:
191
+ """This method is called whenever execution succeeds."""
192
+
193
+
194
+ class StatusClosed(CircuitStatusBase):
195
+ status_type = CircuitStatus.CLOSED
196
+
197
+ def __init__(self, policy: CircuitProtectorPolicy, failure_count: int = 0):
198
+ super().__init__(policy)
199
+ # Initialize failure_count for StatusClosed
200
+ self.failure_count = failure_count
201
+ self.execution_log = BinaryCircularBuffer(size=policy.failure_limit.denominator)
202
+
203
+ def validate_execution(self) -> None:
204
+ # In the CLOSED status, execution is allowed
205
+ pass
206
+
207
+ def mark_failure(self) -> None:
208
+ self.failure_count += 1 # Increment failure count
209
+ self.execution_log.add(False)
210
+ if (
211
+ self.execution_log.is_full
212
+ and self.execution_log.failure_rate >= self.policy.failure_limit
213
+ ):
214
+ self.policy.status = CircuitStatus.OPEN
215
+
216
+ def mark_success(self) -> None:
217
+ self.failure_count = 0 # Reset failure count on success
218
+ self.execution_log.add(True)
219
+
220
+
221
+ class StatusOpen(CircuitStatusBase):
222
+ status_type = CircuitStatus.OPEN
223
+
224
+ def __init__(self, policy: CircuitProtectorPolicy, previous_status: CircuitStatusBase, open_until: float = 0) -> None:
225
+ super().__init__(policy)
226
+ self.execution_log = previous_status.execution_log
227
+ # Handle setting failure_count from previous_status if it exists
228
+ if hasattr(previous_status, 'failure_count'):
229
+ self.failure_count = getattr(previous_status, 'failure_count', 0)
230
+ else:
231
+ self.failure_count = 0
232
+
233
+ # Store the timestamp when the OPEN state should end (cooldown period)
234
+ # If open_until is 0, circuit should be blocked for the full cooldown period
235
+ from datetime import datetime
236
+ if open_until and open_until > 0:
237
+ self.open_until_timestamp = open_until # This is when cooldown ends
238
+ else:
239
+ # Calculate when cooldown should end based on current time and policy cooldown
240
+ self.open_until_timestamp = (datetime.now() + policy.cooldown).timestamp()
241
+
242
+ def validate_execution(self) -> None:
243
+ from datetime import datetime
244
+ # Check if cooldown period has expired
245
+ if datetime.now().timestamp() >= self.open_until_timestamp:
246
+ # Cooldown expired, transition to HALF_OPEN to allow test requests
247
+ self.policy.status = CircuitStatus.HALF_OPEN
248
+ return # Allow execution in HALF_OPEN state
249
+
250
+ # Still in cooldown period, block execution
251
+ raise ProtectedCallError
252
+
253
+ def mark_failure(self) -> None:
254
+ # In OPEN status, errors are not recorded because execution is blocked
255
+ pass
256
+
257
+ def mark_success(self) -> None:
258
+ self.policy.status = CircuitStatus.HALF_OPEN
259
+
260
+
261
+ class StatusHalfOpen(CircuitStatusBase):
262
+ status_type = CircuitStatus.HALF_OPEN
263
+
264
+ def __init__(self, policy: CircuitProtectorPolicy, failure_count: int = 0):
265
+ super().__init__(policy)
266
+ self.failure_count = failure_count
267
+ self.use_success = policy.success_limit != policy.DEFAULT_THRESHOLD
268
+ self.execution_log = BinaryCircularBuffer(
269
+ size=(
270
+ policy.success_limit.denominator
271
+ if self.use_success
272
+ else policy.failure_limit.denominator
273
+ )
274
+ )
275
+
276
+ def validate_execution(self) -> None:
277
+ # In HALF_OPEN status, execution is allowed
278
+ pass
279
+
280
+ def mark_failure(self) -> None:
281
+ self.failure_count += 1
282
+ self.execution_log.add(False)
283
+ self._check_limit()
284
+
285
+ def mark_success(self) -> None:
286
+ self.failure_count = 0 # Reset on success
287
+ self.execution_log.add(True)
288
+ self._check_limit()
289
+
290
+ def _check_limit(self) -> None:
291
+ """Determine whether a limit has been met and the circuit should be opened or closed.
292
+
293
+ The circuit changes status only after the expected number of executions take place.
294
+ If configured, success ratio has precedence over failure ratio.
295
+ """
296
+
297
+ if not self.execution_log.is_full:
298
+ return
299
+
300
+ if self.use_success:
301
+ self.policy.status = (
302
+ CircuitStatus.CLOSED
303
+ if self.execution_log.success_rate >= self.policy.success_limit
304
+ else CircuitStatus.OPEN
305
+ )
306
+ else:
307
+ self.policy.status = (
308
+ CircuitStatus.OPEN
309
+ if self.execution_log.failure_rate >= self.policy.failure_limit
310
+ else CircuitStatus.CLOSED
311
+ )
@@ -0,0 +1,261 @@
1
+ """
2
+ CLI module for Highway Circuit Breaker
3
+ """
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+ from typing import Optional
9
+
10
+ try:
11
+ import psycopg
12
+ except ImportError:
13
+ psycopg = None
14
+
15
+ try:
16
+ from dotenv import load_dotenv
17
+ except ImportError:
18
+ load_dotenv = None
19
+
20
+
21
+ def load_env_vars():
22
+ """Load environment variables from .env file if available."""
23
+ if load_dotenv:
24
+ load_dotenv()
25
+ else:
26
+ print("Warning: python-dotenv not found, skipping .env file loading")
27
+
28
+
29
+ def get_db_config_from_env() -> dict:
30
+ """Get database configuration from environment variables."""
31
+ return {
32
+ 'host': os.getenv('RC_DB_HOST', 'localhost'),
33
+ 'port': int(os.getenv('RC_DB_PORT', '5432')),
34
+ 'dbname': os.getenv('RC_DB_NAME', 'resilient_circuit_db'),
35
+ 'user': os.getenv('RC_DB_USER', 'postgres'),
36
+ 'password': os.getenv('RC_DB_PASSWORD', 'postgres')
37
+ }
38
+
39
+
40
+ def create_postgres_table(config: dict) -> bool:
41
+ """Create the circuit breaker table in PostgreSQL database."""
42
+ if not psycopg:
43
+ print("Error: psycopg is required for PostgreSQL setup. Install with: pip install resilient_circuit[postgres]")
44
+ return False
45
+
46
+ try:
47
+ # Connect to the database
48
+ conn = psycopg.connect(
49
+ host=config['host'],
50
+ port=config['port'],
51
+ dbname=config['dbname'],
52
+ user=config['user'],
53
+ password=config['password']
54
+ )
55
+
56
+ with conn:
57
+ with conn.cursor() as cur:
58
+ # Check if table already exists
59
+ cur.execute("""
60
+ SELECT EXISTS (
61
+ SELECT FROM information_schema.tables
62
+ WHERE table_schema = 'public'
63
+ AND table_name = 'rc_circuit_breakers'
64
+ );
65
+ """)
66
+
67
+ table_exists = cur.fetchone()[0]
68
+ if table_exists:
69
+ print(f"ā„¹ļø Table 'rc_circuit_breakers' already exists, checking for updates...")
70
+
71
+ # Create the circuit breaker table
72
+ cur.execute("""
73
+ CREATE TABLE IF NOT EXISTS rc_circuit_breakers (
74
+ resource_key VARCHAR(255) NOT NULL,
75
+ state VARCHAR(20) NOT NULL CHECK (state IN ('CLOSED', 'OPEN', 'HALF_OPEN')),
76
+ failure_count INTEGER NOT NULL DEFAULT 0 CHECK (failure_count >= 0 AND failure_count <= 2147483647),
77
+ open_until TIMESTAMPTZ,
78
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
79
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
80
+ PRIMARY KEY (resource_key)
81
+ );
82
+ """)
83
+
84
+ # Create optimized indexes
85
+ cur.execute("""
86
+ CREATE INDEX IF NOT EXISTS idx_rc_circuit_breakers_state
87
+ ON rc_circuit_breakers (state);
88
+ """)
89
+
90
+ cur.execute("""
91
+ CREATE INDEX IF NOT EXISTS idx_rc_circuit_breakers_open_until
92
+ ON rc_circuit_breakers (open_until)
93
+ WHERE open_until IS NOT NULL;
94
+ """)
95
+
96
+ cur.execute("""
97
+ CREATE INDEX IF NOT EXISTS idx_rc_circuit_breakers_key_state
98
+ ON rc_circuit_breakers (resource_key, state);
99
+ """)
100
+
101
+ cur.execute("""
102
+ CREATE INDEX IF NOT EXISTS idx_rc_circuit_breakers_state_updated
103
+ ON rc_circuit_breakers (state, updated_at DESC);
104
+ """)
105
+
106
+ # Create trigger function
107
+ cur.execute("""
108
+ CREATE OR REPLACE FUNCTION update_rc_circuit_breakers_updated_at_column()
109
+ RETURNS TRIGGER AS $$
110
+ BEGIN
111
+ NEW.updated_at = NOW();
112
+ RETURN NEW;
113
+ END;
114
+ $$ language 'plpgsql' SET search_path = public;
115
+ """)
116
+
117
+ # Create trigger
118
+ cur.execute("""
119
+ DROP TRIGGER IF EXISTS update_rc_circuit_breakers_updated_at
120
+ ON rc_circuit_breakers;
121
+ """)
122
+
123
+ cur.execute("""
124
+ CREATE TRIGGER update_rc_circuit_breakers_updated_at
125
+ BEFORE UPDATE ON rc_circuit_breakers
126
+ FOR EACH ROW
127
+ WHEN (OLD IS DISTINCT FROM NEW)
128
+ EXECUTE FUNCTION update_rc_circuit_breakers_updated_at_column();
129
+ """)
130
+
131
+ # Add table comments
132
+ cur.execute("""
133
+ COMMENT ON TABLE rc_circuit_breakers IS
134
+ 'Circuit breaker state storage with performance optimizations';
135
+ """)
136
+
137
+ cur.execute("""
138
+ COMMENT ON COLUMN rc_circuit_breakers.state IS
139
+ 'Current state of the circuit breaker: CLOSED, OPEN, or HALF_OPEN';
140
+ """)
141
+
142
+ cur.execute("""
143
+ COMMENT ON COLUMN rc_circuit_breakers.open_until IS
144
+ 'Timestamp when the circuit breaker should transition from OPEN to HALF_OPEN';
145
+ """)
146
+
147
+ cur.execute("""
148
+ COMMENT ON COLUMN rc_circuit_breakers.failure_count IS
149
+ 'Number of consecutive failures since last reset';
150
+ """)
151
+
152
+ conn.commit()
153
+
154
+ if table_exists:
155
+ print(f"āœ… Successfully updated table in database: {config['dbname']}")
156
+ else:
157
+ print(f"āœ… Successfully created table in database: {config['dbname']}")
158
+ return True
159
+
160
+ except psycopg.OperationalError as e:
161
+ if "database" in str(e) and "does not exist" in str(e):
162
+ print(f"āŒ Error: Database '{config['dbname']}' does not exist.")
163
+ print("šŸ’” Please create the database first or update your RC_DB_NAME in the .env file.")
164
+ print(f" You can create it with: createdb -h {config['host']} -p {config['port']} -U {config['user']} {config['dbname']}")
165
+ else:
166
+ print(f"āŒ Database connection error: {e}")
167
+ return False
168
+ except Exception as e:
169
+ print(f"āŒ Error creating table: {e}")
170
+ return False
171
+
172
+
173
+ def run_pg_setup(args: argparse.Namespace) -> int:
174
+ """Run the PostgreSQL setup command."""
175
+ print("šŸš€ Highway Circuit Breaker PostgreSQL Setup")
176
+ print()
177
+
178
+ load_env_vars()
179
+
180
+ # Get config from environment
181
+ config = get_db_config_from_env()
182
+
183
+ print(f"šŸ”§ Using database configuration from environment:")
184
+ print(f" Host: {config['host']}")
185
+ print(f" Port: {config['port']}")
186
+ print(f" Database: {config['dbname']}")
187
+ print(f" User: {config['user']}")
188
+
189
+ if config['dbname'] == 'resilient_circuit_db':
190
+ print(f"\nāš ļø Note: Using default database name '{config['dbname']}'.")
191
+ print(" You can customize this by setting RC_DB_NAME in your .env file.")
192
+
193
+ if args.dry_run:
194
+ print("\nšŸ“ DRY RUN MODE - No changes will be made to the database")
195
+ print("This command would create the required tables and indexes in your PostgreSQL database.")
196
+ return 0
197
+
198
+ # Confirm before proceeding
199
+ if not args.yes:
200
+ response = input(f"\nāš ļø This will create/update the circuit breaker table in '{config['dbname']}'. Continue? [y/N]: ")
201
+ if response.lower() not in ['y', 'yes']:
202
+ print("āŒ Setup cancelled by user.")
203
+ return 1
204
+
205
+ print("\nšŸ“¦ Creating PostgreSQL table and indexes...")
206
+ success = create_postgres_table(config)
207
+
208
+ if success:
209
+ print("\nāœ… PostgreSQL setup completed successfully!")
210
+ print("\nšŸ“‹ The following have been created/updated:")
211
+ print(" - Table: rc_circuit_breakers")
212
+ print(" - Primary key index: rc_circuit_breakers_pkey")
213
+ print(" - Index: idx_rc_circuit_breakers_state")
214
+ print(" - Index: idx_rc_circuit_breakers_open_until")
215
+ print(" - Index: idx_rc_circuit_breakers_key_state")
216
+ print(" - Index: idx_rc_circuit_breakers_state_updated")
217
+ print(" - Trigger: update_rc_circuit_breakers_updated_at")
218
+ print(" - Function: update_rc_circuit_breakers_updated_at_column")
219
+
220
+ print(f"\nšŸ’” The database '{config['dbname']}' is now ready for use with Highway Circuit Breaker!")
221
+ return 0
222
+ else:
223
+ print("\nāŒ PostgreSQL setup failed!")
224
+ return 1
225
+
226
+
227
+ def main():
228
+ """Main CLI entry point."""
229
+ parser = argparse.ArgumentParser(
230
+ prog='highway-circutbreaker-cli',
231
+ description='Highway Circuit Breaker CLI tools'
232
+ )
233
+
234
+ subparsers = parser.add_subparsers(dest='command', help='Available commands')
235
+
236
+ # PostgreSQL setup command
237
+ pg_setup_parser = subparsers.add_parser(
238
+ 'pg-setup',
239
+ help='Setup PostgreSQL table for circuit breaker state storage'
240
+ )
241
+ pg_setup_parser.add_argument(
242
+ '--yes',
243
+ action='store_true',
244
+ help='Skip confirmation prompt'
245
+ )
246
+ pg_setup_parser.add_argument(
247
+ '--dry-run',
248
+ action='store_true',
249
+ help='Show what would be done without making changes'
250
+ )
251
+
252
+ args = parser.parse_args()
253
+
254
+ if args.command == 'pg-setup':
255
+ return run_pg_setup(args)
256
+ else:
257
+ parser.print_help()
258
+ return 1
259
+
260
+ if __name__ == '__main__':
261
+ sys.exit(main())
@@ -0,0 +1,10 @@
1
+ class ProtectionException(Exception):
2
+ pass
3
+
4
+
5
+ class RetryLimitReached(ProtectionException):
6
+ pass
7
+
8
+
9
+ class ProtectedCallError(ProtectionException):
10
+ pass