Dev Logs
/System Design/ Chapter 13: Reliability Patterns
Chapters
  • 01Chapter 1: Introduction to System Design
  • 02Chapter 2: Load Balancing and Traffic Distribution
  • 03Chapter 3: Caching Strategies and Implementation
  • 04Chapter 4: Database Design and Scaling
  • 05Chapter 5: API Design and Gateway Patterns
  • 06Chapter 6: Message Queues and Event Processing
  • 07Chapter 7: Microservices Architecture
  • 08Chapter 8: Distributed Systems Fundamentals
  • 09Chapter 9: Observability & Monitoring
  • 10Chapter 10: Security & Authentication
  • 11Chapter 11: Horizontal vs Vertical Scaling
  • 12Chapter 12: Content Delivery & Edge Computing
  • 13Chapter 13: Reliability Patterns
    • Overview
    • Circuit Breaker Pattern
    • Circuit Breaker Implementation
    • Retry Patterns
    • Advanced Retry Mechanisms
    • Failover Patterns
    • Active-Passive Failover
    • Bulkhead Pattern
    • Resource Isolation
    • Real-World Examples
    • Netflix's Hystrix Pattern
    • AWS Auto Scaling
    • Best Practices
    • . Circuit Breaker Design
    • . Retry Strategy
    • . Failover Planning
    • . Bulkhead Implementation
    • Common Pitfalls
    • . Circuit Breaker Misconfiguration
    • . Retry Logic Issues
    • . Failover Problems
    • . Resource Isolation Failures
  • 14Chapter 14: Data Modeling & Storage
  • 15Chapter 15: Consistency and Consensus
  • 16Chapter 16: Deployment Strategies
  • 17Chapter 17: Disaster Recovery and Business Continuity
  • 18Chapter 18: Performance Optimization
  • 19Chapter 19: Testing Strategies
  • 20Chapter 20: Real-World System Design
  • 21Chapter 21: System Design Interview Preparation
All chapters

Chapter 13: Reliability Patterns

Overview

Reliability patterns are essential for building resilient distributed systems that can handle failures gracefully. This chapter covers circuit breakers, retry patterns, failover mechanisms, and other reliability patterns that ensure system availability and fault tolerance.

Circuit Breaker Pattern

Circuit Breaker Implementation

Advanced Circuit Breaker (Python)

python
import time
import asyncio
import threading
from typing import Callable, Any, Optional, Dict, List
from dataclasses import dataclass
from enum import Enum
import statistics
from collections import deque

class CircuitState(Enum):
    CLOSED = "closed"      # Normal operation
    OPEN = "open"          # Failing, rejecting requests
    HALF_OPEN = "half_open" # Testing if service recovered

@dataclass
class CircuitBreakerConfig:
    failure_threshold: int = 5          # Number of failures to open circuit
    success_threshold: int = 3          # Number of successes to close circuit
    timeout_duration: float = 60.0      # Seconds to wait before trying half-open
    request_timeout: float = 30.0       # Timeout for individual requests
    slow_call_threshold: float = 5.0    # Seconds to consider a call slow
    slow_call_rate_threshold: float = 0.5  # Percentage of slow calls to open circuit
    minimum_throughput: int = 10        # Minimum requests before evaluating
    sliding_window_size: int = 100      # Size of sliding window for metrics

class CircuitBreakerException(Exception):
    """Exception raised when circuit breaker is open"""
    pass

class CircuitBreakerMetrics:
    def __init__(self, window_size: int = 100):
        self.window_size = window_size
        self.requests = deque(maxlen=window_size)
        self.lock = threading.Lock()
    
    def record_success(self, duration: float):
        """Record a successful request"""
        with self.lock:
            self.requests.append({
                'success': True,
                'duration': duration,
                'timestamp': time.time()
            })
    
    def record_failure(self, duration: float, error: Exception):
        """Record a failed request"""
        with self.lock:
            self.requests.append({
                'success': False,
                'duration': duration,
                'timestamp': time.time(),
                'error': str(error)
            })
    
    def get_failure_rate(self) -> float:
        """Get current failure rate"""
        with self.lock:
            if not self.requests:
                return 0.0
            
            failures = sum(1 for req in self.requests if not req['success'])
            return failures / len(self.requests)
    
    def get_slow_call_rate(self, threshold: float) -> float:
        """Get rate of slow calls"""
        with self.lock:
            if not self.requests:
                return 0.0
            
            slow_calls = sum(1 for req in self.requests if req['duration'] > threshold)
            return slow_calls / len(self.requests)
    
    def get_request_count(self) -> int:
        """Get total request count in window"""
        with self.lock:
            return len(self.requests)
    
    def get_average_duration(self) -> float:
        """Get average request duration"""
        with self.lock:
            if not self.requests:
                return 0.0
            
            durations = [req['duration'] for req in self.requests]
            return statistics.mean(durations)
    
    def get_stats(self) -> Dict:
        """Get comprehensive statistics"""
        with self.lock:
            if not self.requests:
                return {
                    'total_requests': 0,
                    'failure_rate': 0.0,
                    'success_rate': 0.0,
                    'average_duration': 0.0,
                    'slow_call_rate': 0.0
                }
            
            total = len(self.requests)
            failures = sum(1 for req in self.requests if not req['success'])
            successes = total - failures
            durations = [req['duration'] for req in self.requests]
            
            return {
                'total_requests': total,
                'failure_rate': failures / total,
                'success_rate': successes / total,
                'average_duration': statistics.mean(durations),
                'min_duration': min(durations),
                'max_duration': max(durations),
                'slow_call_rate': self.get_slow_call_rate(5.0)
            }

class CircuitBreaker:
    def __init__(self, name: str, config: CircuitBreakerConfig = None):
        self.name = name
        self.config = config or CircuitBreakerConfig()
        self.state = CircuitState.CLOSED
        self.metrics = CircuitBreakerMetrics(self.config.sliding_window_size)
        self.last_failure_time = 0
        self.consecutive_failures = 0
        self.consecutive_successes = 0
        self.lock = threading.Lock()
        
        # State change callbacks
        self.on_state_change: Optional[Callable[[CircuitState, CircuitState], None]] = None
    
    async def call(self, func: Callable, *args, **kwargs) -> Any:
        """Execute function with circuit breaker protection"""
        # Check if we can make the call
        if not self._can_execute():
            raise CircuitBreakerException(f"Circuit breaker '{self.name}' is OPEN")
        
        start_time = time.time()
        
        try:
            # Execute the function with timeout
            result = await asyncio.wait_for(
                func(*args, **kwargs) if asyncio.iscoroutinefunction(func) else func(*args, **kwargs),
                timeout=self.config.request_timeout
            )
            
            # Record success
            duration = time.time() - start_time
            self._on_success(duration)
            
            return result
        
        except Exception as e:
            # Record failure
            duration = time.time() - start_time
            self._on_failure(duration, e)
            raise
    
    def _can_execute(self) -> bool:
        """Check if request can be executed based on current state"""
        with self.lock:
            if self.state == CircuitState.CLOSED:
                return True
            elif self.state == CircuitState.OPEN:
                # Check if timeout has passed
                if time.time() - self.last_failure_time >= self.config.timeout_duration:
                    self._transition_to_half_open()
                    return True
                return False
            elif self.state == CircuitState.HALF_OPEN:
                return True
            
            return False
    
    def _on_success(self, duration: float):
        """Handle successful request"""
        self.metrics.record_success(duration)
        
        with self.lock:
            self.consecutive_failures = 0
            
            if self.state == CircuitState.HALF_OPEN:
                self.consecutive_successes += 1
                if self.consecutive_successes >= self.config.success_threshold:
                    self._transition_to_closed()
    
    def _on_failure(self, duration: float, error: Exception):
        """Handle failed request"""
        self.metrics.record_failure(duration, error)
        
        with self.lock:
            self.consecutive_successes = 0
            self.consecutive_failures += 1
            self.last_failure_time = time.time()
            
            if self.state == CircuitState.HALF_OPEN:
                self._transition_to_open()
            elif self.state == CircuitState.CLOSED:
                self._evaluate_circuit_opening()
    
    def _evaluate_circuit_opening(self):
        """Evaluate whether to open the circuit"""
        # Check if we have enough requests to make a decision
        if self.metrics.get_request_count() < self.config.minimum_throughput:
            return
        
        # Check failure rate
        failure_rate = self.metrics.get_failure_rate()
        slow_call_rate = self.metrics.get_slow_call_rate(self.config.slow_call_threshold)
        
        # Open circuit if failure threshold exceeded
        if (self.consecutive_failures >= self.config.failure_threshold or
            failure_rate > 0.5 or  # 50% failure rate
            slow_call_rate > self.config.slow_call_rate_threshold):
            self._transition_to_open()
    
    def _transition_to_open(self):
        """Transition circuit to OPEN state"""
        old_state = self.state
        self.state = CircuitState.OPEN
        self.consecutive_successes = 0
        
        if self.on_state_change:
            self.on_state_change(old_state, self.state)
    
    def _transition_to_half_open(self):
        """Transition circuit to HALF_OPEN state"""
        old_state = self.state
        self.state = CircuitState.HALF_OPEN
        self.consecutive_successes = 0
        self.consecutive_failures = 0
        
        if self.on_state_change:
            self.on_state_change(old_state, self.state)
    
    def _transition_to_closed(self):
        """Transition circuit to CLOSED state"""
        old_state = self.state
        self.state = CircuitState.CLOSED
        self.consecutive_failures = 0
        
        if self.on_state_change:
            self.on_state_change(old_state, self.state)
    
    def force_open(self):
        """Manually force circuit to OPEN state"""
        with self.lock:
            self._transition_to_open()
    
    def force_closed(self):
        """Manually force circuit to CLOSED state"""
        with self.lock:
            self._transition_to_closed()
    
    def get_state(self) -> CircuitState:
        """Get current circuit state"""
        return self.state
    
    def get_stats(self) -> Dict:
        """Get circuit breaker statistics"""
        metrics_stats = self.metrics.get_stats()
        
        return {
            'name': self.name,
            'state': self.state.value,
            'consecutive_failures': self.consecutive_failures,
            'consecutive_successes': self.consecutive_successes,
            'last_failure_time': self.last_failure_time,
            'metrics': metrics_stats
        }

# Circuit Breaker Registry for managing multiple circuit breakers
class CircuitBreakerRegistry:
    def __init__(self):
        self.circuit_breakers: Dict[str, CircuitBreaker] = {}
        self.lock = threading.Lock()
    
    def get_or_create(self, name: str, config: CircuitBreakerConfig = None) -> CircuitBreaker:
        """Get existing circuit breaker or create new one"""
        with self.lock:
            if name not in self.circuit_breakers:
                self.circuit_breakers[name] = CircuitBreaker(name, config)
            return self.circuit_breakers[name]
    
    def get_all_stats(self) -> Dict[str, Dict]:
        """Get statistics for all circuit breakers"""
        with self.lock:
            return {name: cb.get_stats() for name, cb in self.circuit_breakers.items()}
    
    def reset_all(self):
        """Reset all circuit breakers to CLOSED state"""
        with self.lock:
            for cb in self.circuit_breakers.values():
                cb.force_closed()

# Global registry instance
circuit_registry = CircuitBreakerRegistry()

Retry Patterns

Advanced Retry Mechanisms

Exponential Backoff with Jitter (Python)

python
import asyncio
import random
import time
import logging
from typing import Callable, Any, Optional, List, Type
from dataclasses import dataclass
from enum import Enum

class RetryStrategy(Enum):
    FIXED_DELAY = "fixed_delay"
    EXPONENTIAL_BACKOFF = "exponential_backoff"
    LINEAR_BACKOFF = "linear_backoff"
    FIBONACCI_BACKOFF = "fibonacci_backoff"

class JitterType(Enum):
    NONE = "none"
    FULL = "full"
    EQUAL = "equal"
    DECORRELATED = "decorrelated"

@dataclass
class RetryConfig:
    max_attempts: int = 3
    strategy: RetryStrategy = RetryStrategy.EXPONENTIAL_BACKOFF
    base_delay: float = 1.0
    max_delay: float = 60.0
    multiplier: float = 2.0
    jitter_type: JitterType = JitterType.FULL
    retryable_exceptions: List[Type[Exception]] = None
    non_retryable_exceptions: List[Type[Exception]] = None
    retry_on_result: Optional[Callable[[Any], bool]] = None

class RetryExhaustedException(Exception):
    """Exception raised when all retry attempts are exhausted"""
    def __init__(self, attempts: int, last_exception: Exception):
        self.attempts = attempts
        self.last_exception = last_exception
        super().__init__(f"Retry exhausted after {attempts} attempts. Last error: {last_exception}")

class RetryMetrics:
    def __init__(self):
        self.total_attempts = 0
        self.successful_attempts = 0
        self.failed_attempts = 0
        self.retry_attempts = 0
        self.total_delay = 0.0
        self.lock = threading.Lock()
    
    def record_attempt(self, success: bool, delay: float = 0.0, is_retry: bool = False):
        """Record retry attempt"""
        with self.lock:
            self.total_attempts += 1
            if success:
                self.successful_attempts += 1
            else:
                self.failed_attempts += 1
            
            if is_retry:
                self.retry_attempts += 1
                self.total_delay += delay
    
    def get_stats(self) -> Dict:
        """Get retry statistics"""
        with self.lock:
            success_rate = (self.successful_attempts / self.total_attempts * 100) if self.total_attempts > 0 else 0
            avg_delay = (self.total_delay / self.retry_attempts) if self.retry_attempts > 0 else 0
            
            return {
                'total_attempts': self.total_attempts,
                'successful_attempts': self.successful_attempts,
                'failed_attempts': self.failed_attempts,
                'retry_attempts': self.retry_attempts,
                'success_rate_percent': success_rate,
                'average_retry_delay': avg_delay,
                'total_delay': self.total_delay
            }

class RetryHandler:
    def __init__(self, config: RetryConfig = None):
        self.config = config or RetryConfig()
        self.metrics = RetryMetrics()
        self.logger = logging.getLogger(__name__)
    
    async def execute(self, func: Callable, *args, **kwargs) -> Any:
        """Execute function with retry logic"""
        last_exception = None
        
        for attempt in range(1, self.config.max_attempts + 1):
            try:
                start_time = time.time()
                
                # Execute the function
                if asyncio.iscoroutinefunction(func):
                    result = await func(*args, **kwargs)
                else:
                    result = func(*args, **kwargs)
                
                # Check if result should trigger retry
                if self.config.retry_on_result and self.config.retry_on_result(result):
                    raise Exception(f"Result-based retry triggered: {result}")
                
                # Success
                self.metrics.record_attempt(success=True, is_retry=(attempt > 1))
                self.logger.info(f"Function succeeded on attempt {attempt}")
                return result
            
            except Exception as e:
                last_exception = e
                
                # Check if exception is retryable
                if not self._is_retryable_exception(e):
                    self.metrics.record_attempt(success=False)
                    self.logger.error(f"Non-retryable exception: {e}")
                    raise
                
                # If this was the last attempt, don't retry
                if attempt == self.config.max_attempts:
                    self.metrics.record_attempt(success=False)
                    break
                
                # Calculate delay for next attempt
                delay = self._calculate_delay(attempt)
                self.metrics.record_attempt(success=False, delay=delay, is_retry=True)
                
                self.logger.warning(f"Attempt {attempt} failed: {e}. Retrying in {delay:.2f}s")
                
                # Wait before retry
                await asyncio.sleep(delay)
        
        # All attempts exhausted
        raise RetryExhaustedException(self.config.max_attempts, last_exception)
    
    def _is_retryable_exception(self, exception: Exception) -> bool:
        """Check if exception is retryable"""
        # Check non-retryable exceptions first
        if self.config.non_retryable_exceptions:
            for exc_type in self.config.non_retryable_exceptions:
                if isinstance(exception, exc_type):
                    return False
        
        # Check retryable exceptions
        if self.config.retryable_exceptions:
            for exc_type in self.config.retryable_exceptions:
                if isinstance(exception, exc_type):
                    return True
            return False  # Not in retryable list
        
        # Default: retry most exceptions except specific ones
        non_retryable_defaults = [
            ValueError,
            TypeError,
            AttributeError,
            KeyError,
            IndexError
        ]
        
        return not any(isinstance(exception, exc_type) for exc_type in non_retryable_defaults)
    
    def _calculate_delay(self, attempt: int) -> float:
        """Calculate delay for retry attempt"""
        if self.config.strategy == RetryStrategy.FIXED_DELAY:
            delay = self.config.base_delay
        
        elif self.config.strategy == RetryStrategy.EXPONENTIAL_BACKOFF:
            delay = self.config.base_delay * (self.config.multiplier ** (attempt - 1))
        
        elif self.config.strategy == RetryStrategy.LINEAR_BACKOFF:
            delay = self.config.base_delay * attempt
        
        elif self.config.strategy == RetryStrategy.FIBONACCI_BACKOFF:
            delay = self.config.base_delay * self._fibonacci(attempt)
        
        else:
            delay = self.config.base_delay
        
        # Apply maximum delay limit
        delay = min(delay, self.config.max_delay)
        
        # Apply jitter
        delay = self._apply_jitter(delay, attempt)
        
        return delay
    
    def _fibonacci(self, n: int) -> int:
        """Calculate nth Fibonacci number"""
        if n <= 1:
            return n
        a, b = 0, 1
        for _ in range(2, n + 1):
            a, b = b, a + b
        return b
    
    def _apply_jitter(self, delay: float, attempt: int) -> float:
        """Apply jitter to delay"""
        if self.config.jitter_type == JitterType.NONE:
            return delay
        
        elif self.config.jitter_type == JitterType.FULL:
            # Random delay between 0 and calculated delay
            return random.uniform(0, delay)
        
        elif self.config.jitter_type == JitterType.EQUAL:
            # Half fixed delay + half random
            return delay / 2 + random.uniform(0, delay / 2)
        
        elif self.config.jitter_type == JitterType.DECORRELATED:
            # Decorrelated jitter (AWS recommendation)
            if attempt == 1:
                return random.uniform(0, delay)
            else:
                return random.uniform(self.config.base_delay, delay * 3)
        
        return delay
    
    def get_stats(self) -> Dict:
        """Get retry handler statistics"""
        return self.metrics.get_stats()

# Decorator for easy retry functionality
def retry(config: RetryConfig = None):
    """Decorator to add retry functionality to functions"""
    def decorator(func):
        retry_handler = RetryHandler(config)
        
        async def async_wrapper(*args, **kwargs):
            return await retry_handler.execute(func, *args, **kwargs)
        
        def sync_wrapper(*args, **kwargs):
            return asyncio.run(retry_handler.execute(func, *args, **kwargs))
        
        if asyncio.iscoroutinefunction(func):
            return async_wrapper
        else:
            return sync_wrapper
    
    return decorator

# Example usage
@retry(RetryConfig(
    max_attempts=5,
    strategy=RetryStrategy.EXPONENTIAL_BACKOFF,
    base_delay=1.0,
    max_delay=30.0,
    jitter_type=JitterType.FULL,
    retryable_exceptions=[ConnectionError, TimeoutError]
))
async def unreliable_api_call(url: str) -> Dict:
    """Example API call with retry logic"""
    # Simulate API call that might fail
    if random.random() < 0.7:  # 70% failure rate
        raise ConnectionError("Network error")
    
    return {"status": "success", "data": "response"}

Failover Patterns

Active-Passive Failover

Database Failover Implementation (Python)

python
import asyncio
import time
import threading
from typing import List, Optional, Dict, Any, Callable
from dataclasses import dataclass
from enum import Enum
import logging

class NodeStatus(Enum):
    ACTIVE = "active"
    PASSIVE = "passive"
    FAILED = "failed"
    RECOVERING = "recovering"
    MAINTENANCE = "maintenance"

class FailoverStrategy(Enum):
    AUTOMATIC = "automatic"
    MANUAL = "manual"
    SEMI_AUTOMATIC = "semi_automatic"

@dataclass
class DatabaseNode:
    id: str
    host: str
    port: int
    status: NodeStatus
    priority: int  # Lower number = higher priority
    last_health_check: float
    connection_pool: Any = None
    lag_ms: float = 0.0  # Replication lag
    load_percent: float = 0.0

@dataclass
class FailoverConfig:
    health_check_interval: float = 5.0
    failure_threshold: int = 3
    recovery_threshold: int = 2
    max_lag_ms: float = 1000.0
    strategy: FailoverStrategy = FailoverStrategy.AUTOMATIC
    enable_automatic_failback: bool = False
    failback_delay: float = 300.0  # 5 minutes

class HealthChecker:
    def __init__(self, check_function: Callable[[DatabaseNode], bool]):
        self.check_function = check_function
        self.failure_counts: Dict[str, int] = {}
        self.recovery_counts: Dict[str, int] = {}
    
    async def check_node_health(self, node: DatabaseNode) -> bool:
        """Check if a node is healthy"""
        try:
            is_healthy = await self.check_function(node)
            
            if is_healthy:
                # Reset failure count on success
                self.failure_counts[node.id] = 0
                self.recovery_counts[node.id] = self.recovery_counts.get(node.id, 0) + 1
            else:
                # Increment failure count
                self.failure_counts[node.id] = self.failure_counts.get(node.id, 0) + 1
                self.recovery_counts[node.id] = 0
            
            node.last_health_check = time.time()
            return is_healthy
        
        except Exception as e:
            logging.error(f"Health check failed for node {node.id}: {e}")
            self.failure_counts[node.id] = self.failure_counts.get(node.id, 0) + 1
            self.recovery_counts[node.id] = 0
            return False
    
    def should_mark_failed(self, node: DatabaseNode, threshold: int) -> bool:
        """Check if node should be marked as failed"""
        return self.failure_counts.get(node.id, 0) >= threshold
    
    def should_mark_recovered(self, node: DatabaseNode, threshold: int) -> bool:
        """Check if node should be marked as recovered"""
        return self.recovery_counts.get(node.id, 0) >= threshold

class DatabaseFailoverManager:
    def __init__(self, config: FailoverConfig):
        self.config = config
        self.nodes: List[DatabaseNode] = []
        self.active_node: Optional[DatabaseNode] = None
        self.health_checker: Optional[HealthChecker] = None
        self.monitoring_task: Optional[asyncio.Task] = None
        self.lock = threading.Lock()
        self.logger = logging.getLogger(__name__)
        
        # Callbacks
        self.on_failover: Optional[Callable[[DatabaseNode, DatabaseNode], None]] = None
        self.on_failback: Optional[Callable[[DatabaseNode, DatabaseNode], None]] = None
        self.on_node_status_change: Optional[Callable[[DatabaseNode, NodeStatus, NodeStatus], None]] = None
    
    def add_node(self, node: DatabaseNode):
        """Add a database node to the cluster"""
        with self.lock:
            self.nodes.append(node)
            self.nodes.sort(key=lambda n: n.priority)  # Sort by priority
            
            # Set first node as active if none is set
            if not self.active_node and node.status == NodeStatus.ACTIVE:
                self.active_node = node
    
    def set_health_checker(self, health_check_function: Callable[[DatabaseNode], bool]):
        """Set the health check function"""
        self.health_checker = HealthChecker(health_check_function)
    
    async def start_monitoring(self):
        """Start health monitoring"""
        if not self.health_checker:
            raise ValueError("Health checker not configured")
        
        self.monitoring_task = asyncio.create_task(self._monitoring_loop())
        self.logger.info("Database failover monitoring started")
    
    async def stop_monitoring(self):
        """Stop health monitoring"""
        if self.monitoring_task:
            self.monitoring_task.cancel()
            try:
                await self.monitoring_task
            except asyncio.CancelledError:
                pass
        
        self.logger.info("Database failover monitoring stopped")
    
    async def _monitoring_loop(self):
        """Main monitoring loop"""
        while True:
            try:
                await self._check_all_nodes()
                await self._evaluate_failover_conditions()
                await asyncio.sleep(self.config.health_check_interval)
            
            except asyncio.CancelledError:
                break
            except Exception as e:
                self.logger.error(f"Error in monitoring loop: {e}")
                await asyncio.sleep(self.config.health_check_interval)
    
    async def _check_all_nodes(self):
        """Check health of all nodes"""
        tasks = []
        
        for node in self.nodes:
            if node.status != NodeStatus.MAINTENANCE:
                task = asyncio.create_task(self.health_checker.check_node_health(node))
                tasks.append((node, task))
        
        # Wait for all health checks to complete
        for node, task in tasks:
            try:
                is_healthy = await task
                await self._update_node_status(node, is_healthy)
            except Exception as e:
                self.logger.error(f"Health check failed for node {node.id}: {e}")
                await self._update_node_status(node, False)
    
    async def _update_node_status(self, node: DatabaseNode, is_healthy: bool):
        """Update node status based on health check"""
        old_status = node.status
        
        if is_healthy:
            if node.status == NodeStatus.FAILED:
                if self.health_checker.should_mark_recovered(node, self.config.recovery_threshold):
                    node.status = NodeStatus.PASSIVE
                    self.logger.info(f"Node {node.id} recovered")
            elif node.status == NodeStatus.RECOVERING:
                if self.health_checker.should_mark_recovered(node, self.config.recovery_threshold):
                    node.status = NodeStatus.PASSIVE
                    self.logger.info(f"Node {node.id} fully recovered")
        else:
            if node.status in [NodeStatus.ACTIVE, NodeStatus.PASSIVE]:
                if self.health_checker.should_mark_failed(node, self.config.failure_threshold):
                    node.status = NodeStatus.FAILED
                    self.logger.warning(f"Node {node.id} marked as failed")
        
        # Notify status change
        if old_status != node.status and self.on_node_status_change:
            self.on_node_status_change(node, old_status, node.status)
    
    async def _evaluate_failover_conditions(self):
        """Evaluate if failover is needed"""
        with self.lock:
            # Check if active node is still healthy
            if (self.active_node and 
                self.active_node.status == NodeStatus.FAILED and
                self.config.strategy in [FailoverStrategy.AUTOMATIC, FailoverStrategy.SEMI_AUTOMATIC]):
                
                await self._perform_failover()
            
            # Check for automatic failback
            if (self.config.enable_automatic_failback and
                self.config.strategy == FailoverStrategy.AUTOMATIC):
                await self._evaluate_failback()
    
    async def _perform_failover(self):
        """Perform failover to next available node"""
        old_active = self.active_node
        new_active = self._select_best_passive_node()
        
        if not new_active:
            self.logger.critical("No healthy passive nodes available for failover!")
            return
        
        # Perform the failover
        if old_active:
            old_active.status = NodeStatus.FAILED
        
        new_active.status = NodeStatus.ACTIVE
        self.active_node = new_active
        
        self.logger.critical(f"Failover completed: {old_active.id if old_active else 'None'} -> {new_active.id}")
        
        # Notify failover
        if self.on_failover:
            self.on_failover(old_active, new_active)
    
    async def _evaluate_failback(self):
        """Evaluate if failback to higher priority node is possible"""
        if not self.active_node:
            return
        
        # Find highest priority healthy node
        best_node = None
        for node in self.nodes:
            if (node.status == NodeStatus.PASSIVE and
                node.priority < self.active_node.priority and
                time.time() - node.last_health_check < self.config.failback_delay):
                
                if not best_node or node.priority < best_node.priority:
                    best_node = node
        
        if best_node:
            await self._perform_failback(best_node)
    
    async def _perform_failback(self, target_node: DatabaseNode):
        """Perform failback to target node"""
        old_active = self.active_node
        
        # Switch roles
        old_active.status = NodeStatus.PASSIVE
        target_node.status = NodeStatus.ACTIVE
        self.active_node = target_node
        
        self.logger.info(f"Failback completed: {old_active.id} -> {target_node.id}")
        
        # Notify failback
        if self.on_failback:
            self.on_failback(old_active, target_node)
    
    def _select_best_passive_node(self) -> Optional[DatabaseNode]:
        """Select the best passive node for failover"""
        candidates = [
            node for node in self.nodes
            if node.status == NodeStatus.PASSIVE and node.lag_ms <= self.config.max_lag_ms
        ]
        
        if not candidates:
            # Fallback to any passive node if no low-lag nodes available
            candidates = [node for node in self.nodes if node.status == NodeStatus.PASSIVE]
        
        if not candidates:
            return None
        
        # Select node with highest priority (lowest priority number)
        return min(candidates, key=lambda n: (n.priority, n.lag_ms, n.load_percent))
    
    async def manual_failover(self, target_node_id: str) -> bool:
        """Manually trigger failover to specific node"""
        target_node = next((n for n in self.nodes if n.id == target_node_id), None)
        
        if not target_node:
            self.logger.error(f"Target node {target_node_id} not found")
            return False
        
        if target_node.status not in [NodeStatus.PASSIVE, NodeStatus.RECOVERING]:
            self.logger.error(f"Target node {target_node_id} is not in a valid state for failover")
            return False
        
        with self.lock:
            old_active = self.active_node
            
            # Perform manual failover
            if old_active:
                old_active.status = NodeStatus.PASSIVE
            
            target_node.status = NodeStatus.ACTIVE
            self.active_node = target_node
            
            self.logger.info(f"Manual failover completed: {old_active.id if old_active else 'None'} -> {target_node.id}")
            
            # Notify failover
            if self.on_failover:
                self.on_failover(old_active, target_node)
            
            return True
    
    def get_cluster_status(self) -> Dict:
        """Get current cluster status"""
        with self.lock:
            return {
                'active_node': self.active_node.id if self.active_node else None,
                'total_nodes': len(self.nodes),
                'healthy_nodes': len([n for n in self.nodes if n.status in [NodeStatus.ACTIVE, NodeStatus.PASSIVE]]),
                'failed_nodes': len([n for n in self.nodes if n.status == NodeStatus.FAILED]),
                'nodes': [
                    {
                        'id': node.id,
                        'host': node.host,
                        'port': node.port,
                        'status': node.status.value,
                        'priority': node.priority,
                        'lag_ms': node.lag_ms,
                        'load_percent': node.load_percent,
                        'last_health_check': node.last_health_check
                    }
                    for node in self.nodes
                ]
            }

# Example health check function
async def database_health_check(node: DatabaseNode) -> bool:
    """Example database health check"""
    try:
        # Simulate database connection check
        # In real implementation, this would check actual database connectivity
        import random
        
        # Simulate some nodes being unhealthy
        if node.id == "db-node-2" and random.random() < 0.3:
            return False
        
        # Update node metrics
        node.lag_ms = random.uniform(0, 500)
        node.load_percent = random.uniform(10, 80)
        
        return True
    
    except Exception:
        return False

Bulkhead Pattern

Resource Isolation

Thread Pool Isolation (Python)

python
import asyncio
import threading
import time
from typing import Dict, Any, Callable, Optional
from dataclasses import dataclass
from concurrent.futures import ThreadPoolExecutor, Future
from enum import Enum

class BulkheadType(Enum):
    THREAD_POOL = "thread_pool"
    SEMAPHORE = "semaphore"
    CONNECTION_POOL = "connection_pool"

@dataclass
class BulkheadConfig:
    name: str
    bulkhead_type: BulkheadType
    max_concurrent: int
    queue_size: int = 0
    timeout_seconds: float = 30.0
    enable_metrics: bool = True

class BulkheadMetrics:
    def __init__(self):
        self.total_requests = 0
        self.successful_requests = 0
        self.failed_requests = 0
        self.rejected_requests = 0
        self.timeout_requests = 0
        self.current_active = 0
        self.max_active = 0
        self.total_wait_time = 0.0
        self.lock = threading.Lock()
    
    def record_request_start(self):
        with self.lock:
            self.total_requests += 1
            self.current_active += 1
            self.max_active = max(self.max_active, self.current_active)
    
    def record_request_end(self, success: bool, wait_time: float = 0.0, 
                          timeout: bool = False, rejected: bool = False):
        with self.lock:
            self.current_active -= 1
            self.total_wait_time += wait_time
            
            if rejected:
                self.rejected_requests += 1
            elif timeout:
                self.timeout_requests += 1
            elif success:
                self.successful_requests += 1
            else:
                self.failed_requests += 1
    
    def get_stats(self) -> Dict:
        with self.lock:
            avg_wait_time = (self.total_wait_time / self.total_requests) if self.total_requests > 0 else 0
            success_rate = (self.successful_requests / self.total_requests * 100) if self.total_requests > 0 else 0
            
            return {
                'total_requests': self.total_requests,
                'successful_requests': self.successful_requests,
                'failed_requests': self.failed_requests,
                'rejected_requests': self.rejected_requests,
                'timeout_requests': self.timeout_requests,
                'current_active': self.current_active,
                'max_active': self.max_active,
                'success_rate_percent': success_rate,
                'average_wait_time': avg_wait_time
            }

class ThreadPoolBulkhead:
    def __init__(self, config: BulkheadConfig):
        self.config = config
        self.executor = ThreadPoolExecutor(
            max_workers=config.max_concurrent,
            thread_name_prefix=f"bulkhead-{config.name}"
        )
        self.metrics = BulkheadMetrics() if config.enable_metrics else None
        self.semaphore = asyncio.Semaphore(config.max_concurrent)
    
    async def execute(self, func: Callable, *args, **kwargs) -> Any:
        """Execute function in isolated thread pool"""
        start_time = time.time()
        
        if self.metrics:
            self.metrics.record_request_start()
        
        try:
            # Acquire semaphore with timeout
            try:
                await asyncio.wait_for(
                    self.semaphore.acquire(),
                    timeout=self.config.timeout_seconds
                )
            except asyncio.TimeoutError:
                wait_time = time.time() - start_time
                if self.metrics:
                    self.metrics.record_request_end(False, wait_time, timeout=True)
                raise TimeoutError(f"Bulkhead {self.config.name} timeout waiting for slot")
            
            wait_time = time.time() - start_time
            
            try:
                # Execute function in thread pool
                loop = asyncio.get_event_loop()
                result = await loop.run_in_executor(self.executor, func, *args, **kwargs)
                
                if self.metrics:
                    self.metrics.record_request_end(True, wait_time)
                
                return result
            
            finally:
                self.semaphore.release()
        
        except Exception as e:
            wait_time = time.time() - start_time
            if self.metrics:
                self.metrics.record_request_end(False, wait_time)
            raise
    
    def shutdown(self):
        """Shutdown the thread pool"""
        self.executor.shutdown(wait=True)
    
    def get_stats(self) -> Dict:
        """Get bulkhead statistics"""
        base_stats = {
            'name': self.config.name,
            'type': self.config.bulkhead_type.value,
            'max_concurrent': self.config.max_concurrent,
            'timeout_seconds': self.config.timeout_seconds
        }
        
        if self.metrics:
            base_stats.update(self.metrics.get_stats())
        
        return base_stats

class SemaphoreBulkhead:
    def __init__(self, config: BulkheadConfig):
        self.config = config
        self.semaphore = asyncio.Semaphore(config.max_concurrent)
        self.metrics = BulkheadMetrics() if config.enable_metrics else None
    
    async def execute(self, func: Callable, *args, **kwargs) -> Any:
        """Execute function with semaphore-based isolation"""
        start_time = time.time()
        
        if self.metrics:
            self.metrics.record_request_start()
        
        try:
            # Acquire semaphore with timeout
            try:
                await asyncio.wait_for(
                    self.semaphore.acquire(),
                    timeout=self.config.timeout_seconds
                )
            except asyncio.TimeoutError:
                wait_time = time.time() - start_time
                if self.metrics:
                    self.metrics.record_request_end(False, wait_time, timeout=True)
                raise TimeoutError(f"Bulkhead {self.config.name} timeout waiting for slot")
            
            wait_time = time.time() - start_time
            
            try:
                # Execute function
                if asyncio.iscoroutinefunction(func):
                    result = await func(*args, **kwargs)
                else:
                    result = func(*args, **kwargs)
                
                if self.metrics:
                    self.metrics.record_request_end(True, wait_time)
                
                return result
            
            finally:
                self.semaphore.release()
        
        except Exception as e:
            wait_time = time.time() - start_time
            if self.metrics:
                self.metrics.record_request_end(False, wait_time)
            raise
    
    def get_stats(self) -> Dict:
        """Get bulkhead statistics"""
        base_stats = {
            'name': self.config.name,
            'type': self.config.bulkhead_type.value,
            'max_concurrent': self.config.max_concurrent,
            'timeout_seconds': self.config.timeout_seconds
        }
        
        if self.metrics:
            base_stats.update(self.metrics.get_stats())
        
        return base_stats

class BulkheadRegistry:
    def __init__(self):
        self.bulkheads: Dict[str, Any] = {}
        self.lock = threading.Lock()
    
    def create_bulkhead(self, config: BulkheadConfig) -> Any:
        """Create and register a bulkhead"""
        with self.lock:
            if config.name in self.bulkheads:
                return self.bulkheads[config.name]
            
            if config.bulkhead_type == BulkheadType.THREAD_POOL:
                bulkhead = ThreadPoolBulkhead(config)
            elif config.bulkhead_type == BulkheadType.SEMAPHORE:
                bulkhead = SemaphoreBulkhead(config)
            else:
                raise ValueError(f"Unsupported bulkhead type: {config.bulkhead_type}")
            
            self.bulkheads[config.name] = bulkhead
            return bulkhead
    
    def get_bulkhead(self, name: str) -> Optional[Any]:
        """Get existing bulkhead"""
        return self.bulkheads.get(name)
    
    def get_all_stats(self) -> Dict[str, Dict]:
        """Get statistics for all bulkheads"""
        with self.lock:
            return {name: bulkhead.get_stats() for name, bulkhead in self.bulkheads.items()}
    
    def shutdown_all(self):
        """Shutdown all bulkheads"""
        with self.lock:
            for bulkhead in self.bulkheads.values():
                if hasattr(bulkhead, 'shutdown'):
                    bulkhead.shutdown()

# Global registry
bulkhead_registry = BulkheadRegistry()

Real-World Examples

Netflix's Hystrix Pattern

Hystrix-inspired Implementation:

[Service Call] → [Circuit Breaker] → [Bulkhead] → [Timeout] → [Fallback]
                      ↓               ↓           ↓          ↓
                 [Metrics] → [Dashboard] → [Alerts] → [Auto-scaling]

Key Features:

  • Circuit Breaker: Prevents cascading failures
  • Bulkhead: Isolates thread pools per service
  • Timeout: Prevents hanging requests
  • Fallback: Provides degraded functionality
  • Real-time Monitoring: Hystrix Dashboard

AWS Auto Scaling

Multi-tier Scaling Strategy:

[CloudWatch Metrics] → [Auto Scaling Groups] → [Launch Templates]
         ↓                      ↓                    ↓
[Custom Metrics] → [Scaling Policies] → [Instance Management]

Scaling Triggers:

  • CPU Utilization: Scale based on compute load
  • Request Count: Scale based on traffic volume
  • Custom Metrics: Scale based on business metrics
  • Predictive Scaling: Scale based on forecasted demand

Best Practices

1. Circuit Breaker Design

  • Set appropriate thresholds based on service characteristics
  • Implement proper fallback mechanisms for degraded service
  • Monitor circuit breaker state changes and alert on frequent trips
  • Use different circuit breakers for different failure modes

2. Retry Strategy

  • Use exponential backoff with jitter to avoid thundering herd
  • Set maximum retry limits to prevent infinite loops
  • Distinguish between retryable and non-retryable errors
  • Implement circuit breakers alongside retry logic

3. Failover Planning

  • Test failover procedures regularly with chaos engineering
  • Implement health checks that accurately reflect service state
  • Plan for data consistency during failover scenarios
  • Document failover procedures and train operations teams

4. Bulkhead Implementation

  • Isolate critical resources from non-critical workloads
  • Size bulkheads appropriately based on expected load
  • Monitor bulkhead utilization and adjust as needed
  • Implement graceful degradation when bulkheads are full

Common Pitfalls

1. Circuit Breaker Misconfiguration

  • Too sensitive thresholds causing unnecessary circuit trips
  • Too lenient thresholds not protecting against failures
  • Missing fallback logic leaving users with error pages
  • Not considering different failure types (timeout vs error)

2. Retry Logic Issues

  • Retry storms overwhelming already struggling services
  • Infinite retry loops without proper exit conditions
  • Not implementing backoff causing constant hammering
  • Retrying non-idempotent operations causing data corruption

3. Failover Problems

  • Split-brain scenarios with multiple active nodes
  • Data loss during failover due to replication lag
  • Inadequate health checks missing subtle failures
  • Manual failover dependencies slowing recovery

4. Resource Isolation Failures

  • Shared resources defeating bulkhead isolation
  • Undersized bulkheads causing resource starvation
  • Missing monitoring of bulkhead effectiveness
  • Complex dependencies crossing bulkhead boundaries

Next Chapter: Chapter 14: Real-World System Design - Case studies of large-scale systems


PreviousChapter 12: Content Delivery & Edge ComputingNextChapter 14: Data Modeling & Storage

Open source, free forever. Built by iammhador.

Contribute on GitHub