- Remove database.py (SQLite) from tools/storage/ and dss-mvp1/ - Add json_store.py with full JSON-based storage layer - Update 16 files to use new json_store imports - Storage now mirrors DSS canonical structure: .dss/data/ ├── _system/ (config, cache, activity) ├── projects/ (per-project: tokens, components, styles) └── teams/ (team definitions) - Remove Docker files (not needed) - Update DSS_CORE.json to v1.1.0 Philosophy: "Eat our own food" - storage structure matches DSS design 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
265 lines
9.2 KiB
Python
265 lines
9.2 KiB
Python
"""
|
|
Base Integration Classes
|
|
|
|
Provides circuit breaker pattern and base classes for external integrations.
|
|
"""
|
|
|
|
import time
|
|
import asyncio
|
|
from typing import Callable, Any, Optional, Dict
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timedelta
|
|
from enum import Enum
|
|
|
|
from ..config import mcp_config
|
|
from storage.json_store import Cache, read_json, write_json, SYSTEM_DIR
|
|
|
|
|
|
class CircuitState(Enum):
|
|
"""Circuit breaker states"""
|
|
CLOSED = "closed" # Normal operation
|
|
OPEN = "open" # Failing, reject requests
|
|
HALF_OPEN = "half_open" # Testing if service recovered
|
|
|
|
|
|
@dataclass
|
|
class CircuitBreakerStats:
|
|
"""Circuit breaker statistics"""
|
|
state: CircuitState
|
|
failure_count: int
|
|
success_count: int
|
|
last_failure_time: Optional[float]
|
|
last_success_time: Optional[float]
|
|
opened_at: Optional[float]
|
|
next_retry_time: Optional[float]
|
|
|
|
|
|
class CircuitBreakerOpen(Exception):
|
|
"""Exception raised when circuit breaker is open"""
|
|
pass
|
|
|
|
|
|
class CircuitBreaker:
|
|
"""
|
|
Circuit Breaker pattern implementation.
|
|
|
|
Protects external service calls from cascading failures.
|
|
Three states: CLOSED (normal), OPEN (failing), HALF_OPEN (testing).
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
integration_type: str,
|
|
failure_threshold: int = None,
|
|
timeout_seconds: int = None,
|
|
half_open_max_calls: int = 3
|
|
):
|
|
"""
|
|
Args:
|
|
integration_type: Type of integration (figma, jira, confluence, etc.)
|
|
failure_threshold: Number of failures before opening circuit
|
|
timeout_seconds: Seconds to wait before trying again
|
|
half_open_max_calls: Max successful calls in half-open before closing
|
|
"""
|
|
self.integration_type = integration_type
|
|
self.failure_threshold = failure_threshold or mcp_config.CIRCUIT_BREAKER_FAILURE_THRESHOLD
|
|
self.timeout_seconds = timeout_seconds or mcp_config.CIRCUIT_BREAKER_TIMEOUT_SECONDS
|
|
self.half_open_max_calls = half_open_max_calls
|
|
|
|
# In-memory state (could be moved to Redis for distributed setup)
|
|
self.state = CircuitState.CLOSED
|
|
self.failure_count = 0
|
|
self.success_count = 0
|
|
self.last_failure_time: Optional[float] = None
|
|
self.last_success_time: Optional[float] = None
|
|
self.opened_at: Optional[float] = None
|
|
|
|
async def call(self, func: Callable, *args, **kwargs) -> Any:
|
|
"""
|
|
Call a function through the circuit breaker.
|
|
|
|
Args:
|
|
func: Function to call (can be sync or async)
|
|
*args, **kwargs: Arguments to pass to func
|
|
|
|
Returns:
|
|
Function result
|
|
|
|
Raises:
|
|
CircuitBreakerOpen: If circuit is open
|
|
Exception: Original exception from func if it fails
|
|
"""
|
|
# Check circuit state
|
|
if self.state == CircuitState.OPEN:
|
|
# Check if timeout has elapsed
|
|
if time.time() - self.opened_at < self.timeout_seconds:
|
|
await self._record_failure("Circuit breaker is OPEN", db_only=True)
|
|
raise CircuitBreakerOpen(
|
|
f"{self.integration_type} service is temporarily unavailable. "
|
|
f"Retry after {self._seconds_until_retry():.0f}s"
|
|
)
|
|
else:
|
|
# Timeout elapsed, move to HALF_OPEN
|
|
self.state = CircuitState.HALF_OPEN
|
|
self.success_count = 0
|
|
|
|
# Execute function
|
|
try:
|
|
# Handle both sync and async functions
|
|
if asyncio.iscoroutinefunction(func):
|
|
result = await func(*args, **kwargs)
|
|
else:
|
|
result = func(*args, **kwargs)
|
|
|
|
# Success!
|
|
await self._record_success()
|
|
|
|
# If in HALF_OPEN, check if we can close the circuit
|
|
if self.state == CircuitState.HALF_OPEN:
|
|
if self.success_count >= self.half_open_max_calls:
|
|
self.state = CircuitState.CLOSED
|
|
self.failure_count = 0
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
# Failure
|
|
await self._record_failure(str(e))
|
|
|
|
# Check if we should open the circuit
|
|
if self.failure_count >= self.failure_threshold:
|
|
self.state = CircuitState.OPEN
|
|
self.opened_at = time.time()
|
|
|
|
raise
|
|
|
|
async def _record_success(self):
|
|
"""Record successful call"""
|
|
self.success_count += 1
|
|
self.last_success_time = time.time()
|
|
|
|
# Update database
|
|
await self._update_health_db(is_healthy=True, error=None)
|
|
|
|
async def _record_failure(self, error_message: str, db_only: bool = False):
|
|
"""Record failed call"""
|
|
if not db_only:
|
|
self.failure_count += 1
|
|
self.last_failure_time = time.time()
|
|
|
|
# Update database
|
|
await self._update_health_db(is_healthy=False, error=error_message)
|
|
|
|
async def _update_health_db(self, is_healthy: bool, error: Optional[str]):
|
|
"""Update integration health in database"""
|
|
loop = asyncio.get_event_loop()
|
|
|
|
def update_db():
|
|
try:
|
|
with get_connection() as conn:
|
|
circuit_open_until = None
|
|
if self.state == CircuitState.OPEN and self.opened_at:
|
|
circuit_open_until = datetime.fromtimestamp(
|
|
self.opened_at + self.timeout_seconds
|
|
).isoformat()
|
|
|
|
if is_healthy:
|
|
conn.execute(
|
|
"""
|
|
UPDATE integration_health
|
|
SET is_healthy = 1,
|
|
failure_count = 0,
|
|
last_success_at = CURRENT_TIMESTAMP,
|
|
circuit_open_until = NULL,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE integration_type = ?
|
|
""",
|
|
(self.integration_type,)
|
|
)
|
|
else:
|
|
conn.execute(
|
|
"""
|
|
UPDATE integration_health
|
|
SET is_healthy = 0,
|
|
failure_count = ?,
|
|
last_failure_at = CURRENT_TIMESTAMP,
|
|
circuit_open_until = ?,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE integration_type = ?
|
|
""",
|
|
(self.failure_count, circuit_open_until, self.integration_type)
|
|
)
|
|
except Exception as e:
|
|
print(f"Error updating integration health: {e}")
|
|
|
|
await loop.run_in_executor(None, update_db)
|
|
|
|
def _seconds_until_retry(self) -> float:
|
|
"""Get seconds until circuit can be retried"""
|
|
if self.state != CircuitState.OPEN or not self.opened_at:
|
|
return 0
|
|
elapsed = time.time() - self.opened_at
|
|
remaining = self.timeout_seconds - elapsed
|
|
return max(0, remaining)
|
|
|
|
def get_stats(self) -> CircuitBreakerStats:
|
|
"""Get current circuit breaker statistics"""
|
|
next_retry_time = None
|
|
if self.state == CircuitState.OPEN and self.opened_at:
|
|
next_retry_time = self.opened_at + self.timeout_seconds
|
|
|
|
return CircuitBreakerStats(
|
|
state=self.state,
|
|
failure_count=self.failure_count,
|
|
success_count=self.success_count,
|
|
last_failure_time=self.last_failure_time,
|
|
last_success_time=self.last_success_time,
|
|
opened_at=self.opened_at,
|
|
next_retry_time=next_retry_time
|
|
)
|
|
|
|
|
|
class BaseIntegration:
|
|
"""Base class for all external integrations"""
|
|
|
|
def __init__(self, integration_type: str, config: Dict[str, Any]):
|
|
"""
|
|
Args:
|
|
integration_type: Type of integration (figma, jira, etc.)
|
|
config: Integration configuration (decrypted)
|
|
"""
|
|
self.integration_type = integration_type
|
|
self.config = config
|
|
self.circuit_breaker = CircuitBreaker(integration_type)
|
|
|
|
async def call_api(self, func: Callable, *args, **kwargs) -> Any:
|
|
"""
|
|
Call external API through circuit breaker.
|
|
|
|
Args:
|
|
func: API function to call
|
|
*args, **kwargs: Arguments to pass
|
|
|
|
Returns:
|
|
API response
|
|
|
|
Raises:
|
|
CircuitBreakerOpen: If circuit is open
|
|
Exception: Original API exception
|
|
"""
|
|
return await self.circuit_breaker.call(func, *args, **kwargs)
|
|
|
|
def get_health(self) -> Dict[str, Any]:
|
|
"""Get integration health status"""
|
|
stats = self.circuit_breaker.get_stats()
|
|
return {
|
|
"integration_type": self.integration_type,
|
|
"state": stats.state.value,
|
|
"is_healthy": stats.state == CircuitState.CLOSED,
|
|
"failure_count": stats.failure_count,
|
|
"success_count": stats.success_count,
|
|
"last_failure_time": stats.last_failure_time,
|
|
"last_success_time": stats.last_success_time,
|
|
"next_retry_time": stats.next_retry_time
|
|
}
|