Files
rpa_vision_v3/core/system/circuit_breaker.py
Dom 4b96524964 feat(system): Ajouter gestionnaires backup et version pour Dashboard
BackupExporter (backup_exporter.py):
- Export complet (workflows, correction packs, coaching sessions, configs)
- Export sélectif (workflows only, configs only, etc.)
- Export modèles entraînés opt-in (embeddings, FAISS anonymisés)
- Sanitisation des configs (masquage des secrets)
- Statistiques de backup disponibles

VersionManager (version_manager.py):
- Suivi de version avec composants
- Vérification des mises à jour (manifest local)
- Vérification intégrité packages (SHA-256)
- Création/restauration de backups pour rollback
- Information système complète

Ces modules supportent les fonctionnalités Dashboard:
- Téléchargement sauvegardes par le client
- Mise à jour du système
- Rollback en cas de problème

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 15:34:51 +01:00

343 lines
13 KiB
Python

"""
Circuit Breaker pour la gestion des échecs et auto-healing - Fiche #22
Implémente un mécanisme de circuit breaker avec fenêtres glissantes,
seuils de déclenchement et gestion des échecs pour le système RPA Vision V3.
Auteur: Kiro AI Assistant - 7 janvier 2026
"""
import time
from datetime import datetime, timedelta
from collections import defaultdict, deque
from typing import Dict, List, Optional, Any
from core.system.models import SimpleFailureEvent
class SlidingWindow:
"""Fenêtre glissante pour compter les échecs dans une période donnée"""
def __init__(self, window_duration_s: int):
self.window_duration_s = window_duration_s
self.failures = deque()
def add_failure(self, failure_event):
"""Ajouter un échec à la fenêtre"""
self.failures.append(failure_event)
self._cleanup_old_failures()
def get_failure_count(self) -> int:
"""Obtenir le nombre d'échecs dans la fenêtre actuelle"""
self._cleanup_old_failures()
return len(self.failures)
def get_failure_types(self) -> Dict[str, int]:
"""Obtenir les types d'échecs et leurs compteurs"""
self._cleanup_old_failures()
failure_types = defaultdict(int)
for failure in self.failures:
failure_types[failure.failure_type] += 1
return dict(failure_types)
def _cleanup_old_failures(self):
"""Nettoyer les échecs expirés de la fenêtre"""
cutoff_time = datetime.now() - timedelta(seconds=self.window_duration_s)
while self.failures and self.failures[0].timestamp < cutoff_time:
self.failures.popleft()
class CircuitBreaker:
"""
Circuit Breaker pour la gestion des échecs et déclenchement des modes de récupération.
Gère trois niveaux de déclenchement:
- DEGRADED: Échecs consécutifs sur une étape spécifique
- QUARANTINED: Trop d'échecs dans une fenêtre de temps pour un workflow
- GLOBAL_PAUSE: Trop d'échecs globaux dans le système
"""
def __init__(self, policy: Dict[str, Any]):
"""
Initialiser le CircuitBreaker avec une politique de gestion des échecs.
Args:
policy: Configuration avec les seuils et fenêtres
- step_fail_streak_to_degraded: Nombre d'échecs consécutifs pour DEGRADED
- workflow_fail_window_s: Durée de la fenêtre pour workflow (secondes)
- workflow_fail_max_in_window: Max échecs par workflow dans la fenêtre
- global_fail_max_in_window: Max échecs globaux dans la fenêtre
- success_reset_threshold: Nombre de succès pour reset des échecs
"""
self.policy = policy
# Échecs consécutifs par étape (workflow_id:step_id -> List[SimpleFailureEvent])
self.step_consecutive_failures: Dict[str, List[SimpleFailureEvent]] = defaultdict(list)
# Compteurs de succès par étape pour reset
self.step_success_counts: Dict[str, int] = defaultdict(int)
# Fenêtres glissantes par workflow
self.workflow_windows: Dict[str, SlidingWindow] = {}
# Fenêtre glissante globale
self.global_window = SlidingWindow(policy.get('workflow_fail_window_s', 600))
def record_failure(self, workflow_id: str, step_id: str, failure_type: str):
"""
Enregistrer un échec pour une étape spécifique.
Args:
workflow_id: Identifiant du workflow
step_id: Identifiant de l'étape
failure_type: Type d'échec (TARGET_NOT_FOUND, TIMEOUT, etc.)
"""
failure_event = SimpleFailureEvent(
timestamp=datetime.now(),
workflow_id=workflow_id,
step_id=step_id,
failure_type=failure_type
)
# Enregistrer l'échec consécutif pour l'étape
step_key = f"{workflow_id}:{step_id}"
self.step_consecutive_failures[step_key].append(failure_event)
# Reset le compteur de succès pour cette étape
self.step_success_counts[step_key] = 0
# Ajouter à la fenêtre du workflow
if workflow_id not in self.workflow_windows:
self.workflow_windows[workflow_id] = SlidingWindow(
self.policy.get('workflow_fail_window_s', 600)
)
self.workflow_windows[workflow_id].add_failure(failure_event)
# Ajouter à la fenêtre globale
self.global_window.add_failure(failure_event)
def record_success(self, workflow_id: str, step_id: str):
"""
Enregistrer un succès pour une étape spécifique.
Args:
workflow_id: Identifiant du workflow
step_id: Identifiant de l'étape
"""
step_key = f"{workflow_id}:{step_id}"
self.step_success_counts[step_key] += 1
# Si on atteint le seuil de succès, reset les échecs consécutifs
success_threshold = self.policy.get('success_reset_threshold', 2)
if self.step_success_counts[step_key] >= success_threshold:
self.step_consecutive_failures[step_key] = []
def should_trigger_degraded(self, workflow_id: str, step_id: str) -> bool:
"""
Vérifier si le mode DEGRADED doit être déclenché pour une étape.
Args:
workflow_id: Identifiant du workflow
step_id: Identifiant de l'étape
Returns:
True si le mode DEGRADED doit être déclenché
"""
step_key = f"{workflow_id}:{step_id}"
consecutive_failures = len(self.step_consecutive_failures[step_key])
threshold = self.policy.get('step_fail_streak_to_degraded', 3)
return consecutive_failures >= threshold
def should_trigger_quarantine(self, workflow_id: str) -> bool:
"""
Vérifier si le mode QUARANTINED doit être déclenché pour un workflow.
Args:
workflow_id: Identifiant du workflow
Returns:
True si le mode QUARANTINED doit être déclenché
"""
if workflow_id not in self.workflow_windows:
return False
failure_count = self.workflow_windows[workflow_id].get_failure_count()
threshold = self.policy.get('workflow_fail_max_in_window', 10)
return failure_count >= threshold
def should_trigger_global_pause(self) -> bool:
"""
Vérifier si le PAUSE global doit être déclenché.
Returns:
True si le PAUSE global doit être déclenché
"""
global_failure_count = self.global_window.get_failure_count()
threshold = self.policy.get('global_fail_max_in_window', 30)
return global_failure_count >= threshold
def get_failure_counts(self, workflow_id: str) -> Dict[str, Any]:
"""
Obtenir les compteurs d'échecs pour un workflow.
Args:
workflow_id: Identifiant du workflow
Returns:
Dictionnaire avec les compteurs d'échecs
"""
# Compteurs d'échecs consécutifs par étape
step_consecutive = {}
for step_key, failures in self.step_consecutive_failures.items():
if step_key.startswith(f"{workflow_id}:"):
step_id = step_key.split(":", 1)[1]
step_consecutive[step_id] = len(failures)
# Compteur d'échecs dans la fenêtre du workflow
workflow_window_count = 0
if workflow_id in self.workflow_windows:
workflow_window_count = self.workflow_windows[workflow_id].get_failure_count()
return {
'step_consecutive': step_consecutive,
'workflow_window': workflow_window_count,
'global_window': self.global_window.get_failure_count(),
'window_duration_s': self.policy.get('workflow_fail_window_s', 600)
}
def get_step_failure_history(self, workflow_id: str, step_id: str, limit: Optional[int] = None) -> List[SimpleFailureEvent]:
"""
Obtenir l'historique des échecs pour une étape spécifique.
Args:
workflow_id: Identifiant du workflow
step_id: Identifiant de l'étape
limit: Nombre maximum d'échecs à retourner (les plus récents)
Returns:
Liste des échecs pour l'étape
"""
step_key = f"{workflow_id}:{step_id}"
failures = self.step_consecutive_failures[step_key]
if limit is not None:
failures = failures[-limit:]
return failures
def get_workflow_failure_types(self, workflow_id: str) -> Dict[str, int]:
"""
Obtenir les types d'échecs et leurs compteurs pour un workflow.
Args:
workflow_id: Identifiant du workflow
Returns:
Dictionnaire des types d'échecs et leurs compteurs
"""
if workflow_id not in self.workflow_windows:
return {}
return self.workflow_windows[workflow_id].get_failure_types()
def cleanup_old_data(self):
"""
Nettoyer les anciennes données expirées.
"""
# Nettoyer les fenêtres glissantes (se fait automatiquement)
for workflow_window in self.workflow_windows.values():
workflow_window._cleanup_old_failures()
self.global_window._cleanup_old_failures()
# Nettoyer les échecs consécutifs très anciens (plus de 1 heure)
cutoff_time = datetime.now() - timedelta(hours=1)
keys_to_clean = []
for step_key, failures in self.step_consecutive_failures.items():
# Garder seulement les échecs récents
recent_failures = [f for f in failures if f.timestamp > cutoff_time]
if recent_failures:
self.step_consecutive_failures[step_key] = recent_failures
else:
keys_to_clean.append(step_key)
# Supprimer les clés vides
for key in keys_to_clean:
del self.step_consecutive_failures[key]
if key in self.step_success_counts:
del self.step_success_counts[key]
def reset_step_failures(self, workflow_id: str, step_id: str):
"""
Réinitialiser manuellement les échecs pour une étape spécifique.
Args:
workflow_id: Identifiant du workflow
step_id: Identifiant de l'étape
"""
step_key = f"{workflow_id}:{step_id}"
if step_key in self.step_consecutive_failures:
del self.step_consecutive_failures[step_key]
self.step_success_counts[step_key] = 0
def reset_workflow_failures(self, workflow_id: str):
"""
Réinitialiser manuellement tous les échecs pour un workflow.
Args:
workflow_id: Identifiant du workflow
"""
# Reset la fenêtre du workflow
if workflow_id in self.workflow_windows:
self.workflow_windows[workflow_id].failures.clear()
# Reset tous les échecs consécutifs pour ce workflow
keys_to_remove = []
for step_key in self.step_consecutive_failures.keys():
if step_key.startswith(f"{workflow_id}:"):
keys_to_remove.append(step_key)
for key in keys_to_remove:
del self.step_consecutive_failures[key]
if key in self.step_success_counts:
del self.step_success_counts[key]
def get_status_summary(self) -> Dict[str, Any]:
"""
Obtenir un résumé complet du statut du CircuitBreaker.
Returns:
Dictionnaire avec le statut complet
"""
# Statistiques globales
global_failure_types = self.global_window.get_failure_types()
# Compter les workflows avec échecs
workflows_with_failures = len([wf for wf in self.workflow_windows.keys()
if self.workflow_windows[wf].get_failure_count() > 0])
# Compter les étapes avec échecs consécutifs
steps_with_failures = len([key for key, failures in self.step_consecutive_failures.items()
if len(failures) > 0])
return {
'timestamp': datetime.now().isoformat(),
'policy': self.policy,
'global_stats': {
'global_failures_in_window': self.global_window.get_failure_count(),
'workflows_with_failures': workflows_with_failures,
'steps_with_consecutive_failures': steps_with_failures,
'global_failure_types': global_failure_types
},
'thresholds': {
'step_consecutive_to_degraded': self.policy.get('step_fail_streak_to_degraded', 3),
'workflow_window_to_quarantine': self.policy.get('workflow_fail_max_in_window', 10),
'global_window_to_pause': self.policy.get('global_fail_max_in_window', 30),
'window_duration_s': self.policy.get('workflow_fail_window_s', 600)
}
}