Files

176 lines
6.4 KiB
Python
Raw Permalink Normal View History

2026-02-05 00:25:38 +08:00
# -*- coding: utf-8 -*-
"""
===================================
Circuit Breaker
2026-02-05 00:25:38 +08:00
===================================
Refer to daily_stock_analysis project implementation
Used to manage the fuse/cooling status of data sources to avoid repeated requests when continuous failures occur.
2026-02-05 00:25:38 +08:00
State machine:
CLOSED (normal) --failed N times --> OPEN (melted) --cooling time expired --> HALF_OPEN (half open)
HALF_OPEN --Success--> CLOSED
HALF_OPEN --Failure--> OPEN
2026-02-05 00:25:38 +08:00
"""
import logging
import time
2026-02-05 00:25:38 +08:00
from enum import Enum
from typing import Any, Dict, Optional
2026-02-05 00:25:38 +08:00
logger = logging.getLogger(__name__)
class CircuitState(Enum):
"""fuse status"""
CLOSED = "closed" # normal state
OPEN = "open" # Fuse status (not available)
HALF_OPEN = "half_open" # Half-open state (exploratory request)
2026-02-05 00:25:38 +08:00
class CircuitBreaker:
"""
Circuit Breakers - Manage the blown/cooling status of data sources
Strategy:
- Enter the fuse state after N consecutive failures
- Skip this data source during the circuit breaker period
- Automatically returns to half-open state after cooling time
- In the half-open state, if a single success is successful, it will be fully restored, if it fails, the fuse will continue to be broken.
2026-02-05 00:25:38 +08:00
"""
2026-02-05 00:25:38 +08:00
def __init__(
self,
failure_threshold: int = 3, # Continuous failure threshold
cooldown_seconds: float = 300.0, # Cooling time (seconds), default 5 minutes
half_open_max_calls: int = 1, # Maximum number of attempts in half-open state
2026-02-05 00:25:38 +08:00
):
self.failure_threshold = failure_threshold
self.cooldown_seconds = cooldown_seconds
self.half_open_max_calls = half_open_max_calls
# Status of each data source {source_name: {state, failures, last_failure_time, half_open_calls}}
2026-02-05 00:25:38 +08:00
self._states: Dict[str, Dict[str, Any]] = {}
2026-02-05 00:25:38 +08:00
def _get_state(self, source: str) -> Dict[str, Any]:
"""Get or initialize data source status"""
2026-02-05 00:25:38 +08:00
if source not in self._states:
self._states[source] = {
"state": CircuitState.CLOSED,
"failures": 0,
"last_failure_time": 0.0,
"half_open_calls": 0,
"last_error": None,
2026-02-05 00:25:38 +08:00
}
return self._states[source]
2026-02-05 00:25:38 +08:00
def is_available(self, source: str) -> bool:
"""
Check if the data source is available
Return True to indicate that the request can be attempted
Return False to indicate that the data source should be skipped
2026-02-05 00:25:38 +08:00
"""
state = self._get_state(source)
current_time = time.time()
if state["state"] == CircuitState.CLOSED:
2026-02-05 00:25:38 +08:00
return True
if state["state"] == CircuitState.OPEN:
# Check cool down time
time_since_failure = current_time - state["last_failure_time"]
2026-02-05 00:25:38 +08:00
if time_since_failure >= self.cooldown_seconds:
# Cooling is completed and enters the half-open state
state["state"] = CircuitState.HALF_OPEN
state["half_open_calls"] = 0
logger.info(f"[circuit breaker] {source} cooldown finished, entering half-open state")
2026-02-05 00:25:38 +08:00
return True
else:
remaining = self.cooldown_seconds - time_since_failure
logger.debug(f"[circuit breaker] {source} is open, remaining cooldown: {remaining:.0f}s")
2026-02-05 00:25:38 +08:00
return False
if state["state"] == CircuitState.HALF_OPEN:
# Limit the number of requests in the half-open state
if state["half_open_calls"] < self.half_open_max_calls:
2026-02-05 00:25:38 +08:00
return True
return False
2026-02-05 00:25:38 +08:00
return True
2026-02-05 00:25:38 +08:00
def record_success(self, source: str) -> None:
"""Log successful request"""
2026-02-05 00:25:38 +08:00
state = self._get_state(source)
if state["state"] == CircuitState.HALF_OPEN:
# Successful in half-open state, full recovery
logger.info(f"[circuit breaker] {source} request succeeded in half-open state, fully recovered")
# reset state
state["state"] = CircuitState.CLOSED
state["failures"] = 0
state["half_open_calls"] = 0
state["last_error"] = None
2026-02-05 00:25:38 +08:00
def record_failure(self, source: str, error: Optional[str] = None) -> None:
"""Logging failed requests"""
2026-02-05 00:25:38 +08:00
state = self._get_state(source)
current_time = time.time()
state["failures"] += 1
state["last_failure_time"] = current_time
state["last_error"] = error
if state["state"] == CircuitState.HALF_OPEN:
# Fails in half-open state and continues to fuse
state["state"] = CircuitState.OPEN
state["half_open_calls"] = 0
logger.warning(
f"[circuit breaker] {source} request failed in half-open state, staying open for {self.cooldown_seconds}s"
)
elif state["failures"] >= self.failure_threshold:
# reaches the threshold and enters the circuit breaker
state["state"] = CircuitState.OPEN
logger.warning(
f"[circuit breaker] {source} failed {state['failures']} times consecutively and is now open "
f"(cooldown {self.cooldown_seconds}s)"
)
2026-02-05 00:25:38 +08:00
if error:
logger.warning(f"[circuit breaker] last error: {error}")
2026-02-05 00:25:38 +08:00
def get_status(self) -> Dict[str, Dict[str, Any]]:
"""Get all data source status"""
2026-02-05 00:25:38 +08:00
return {
source: {"state": info["state"].value, "failures": info["failures"], "last_error": info["last_error"]}
2026-02-05 00:25:38 +08:00
for source, info in self._states.items()
}
2026-02-05 00:25:38 +08:00
def reset(self, source: Optional[str] = None) -> None:
"""Reset fuse status"""
2026-02-05 00:25:38 +08:00
if source:
if source in self._states:
del self._states[source]
logger.info(f"[circuit breaker] reset breaker state for {source}")
2026-02-05 00:25:38 +08:00
else:
self._states.clear()
logger.info("[circuit breaker] reset breaker state for all data sources")
2026-02-05 00:25:38 +08:00
# ============================================
# Global circuit breaker example
2026-02-05 00:25:38 +08:00
# ============================================
# Real-time market circuit breaker (more stringent strategy)
2026-02-05 00:25:38 +08:00
_realtime_circuit_breaker = CircuitBreaker(
failure_threshold=2, # Failed 2 times in a row
cooldown_seconds=180.0, # Cool for 3 minutes
half_open_max_calls=1,
2026-02-05 00:25:38 +08:00
)
def get_realtime_circuit_breaker() -> CircuitBreaker:
"""Get realtime market breaker"""
2026-02-05 00:25:38 +08:00
return _realtime_circuit_breaker