""" Contrôle de santé périodique des services de fond. Ces services ne sont pas exposés par Traefik : aucune sonde HTTP externe ne peut les atteindre. Sans ce module, un crawler dont FlareSolverr est injoignable ou un worker à court de quota reste muet, et le seul symptôme est l'absence de données nouvelles — qu'il faut remarquer soi-même. Chaque sonde renvoie (nom, ok, détail). Le résultat agrégé est écrit dans `service_health` (une ligne par service, écrasée), lu par le dashboard admin. Aucune sonde ne peut interrompre le service : toute exception est convertie en échec de sonde. Un contrôle de santé qui fait tomber ce qu'il surveille serait pire que pas de contrôle du tout. """ import logging import time log = logging.getLogger(__name__) # Intervalle minimal entre deux contrôles complets. DEFAULT_INTERVAL_S = 3600 _last_run = 0.0 def probe(name, fn): """Exécute une sonde et convertit toute exception en échec.""" try: ok, detail = fn() return name, bool(ok), (detail or "") except Exception as e: # noqa: BLE001 - une sonde ne doit jamais propager return name, False, f"{type(e).__name__}: {e}" def database_probe(get_conn): """La base répond-elle ?""" def _run(): with get_conn() as conn: with conn.cursor() as cur: cur.execute("SELECT 1") cur.fetchone() return True, "connexion établie" return _run def http_probe(session_get, url, expect_below=500, timeout=10): """Une dépendance HTTP répond-elle sans erreur serveur ?""" def _run(): r = session_get(url, timeout=timeout) code = getattr(r, "status_code", 0) return code < expect_below, f"HTTP {code}" return _run def freshness_probe(get_conn, sql, max_age_hours, label): """Les données progressent-elles ? `sql` doit renvoyer un unique timestamp (le plus récent). Une base vide (NULL) n'est pas un échec : c'est un état légitime au premier démarrage. """ def _run(): with get_conn() as conn: with conn.cursor() as cur: cur.execute(sql) row = cur.fetchone() latest = row[0] if row else None if latest is None: return True, f"{label}: aucune donnée pour l'instant" with get_conn() as conn: with conn.cursor() as cur: cur.execute( "SELECT EXTRACT(EPOCH FROM (now() - %s)) / 3600.0", (latest,) ) age_h = float(cur.fetchone()[0]) return age_h <= max_age_hours, f"{label}: {age_h:.1f} h (seuil {max_age_hours} h)" return _run def write_health(get_conn, service, results): """Enregistre le résultat agrégé. N'échoue jamais bruyamment.""" import json checks = {name: ok for name, ok, _ in results} failed = [f"{name} — {detail}" for name, ok, detail in results if not ok] ok = not failed try: with get_conn() as conn: with conn.cursor() as cur: cur.execute( """ INSERT INTO service_health (service, ok, checks, error, checked_at) VALUES (%s, %s, %s::jsonb, %s, now()) ON CONFLICT (service) DO UPDATE SET ok = EXCLUDED.ok, checks = EXCLUDED.checks, error = EXCLUDED.error, checked_at = EXCLUDED.checked_at """, (service, ok, json.dumps(checks), failed[0] if failed else None), ) except Exception as e: # noqa: BLE001 log.warning(f"[health] écriture impossible: {e}") return ok, failed def run_checks(get_conn, service, probes, log_event=None): """Exécute toutes les sondes, enregistre le résultat, journalise les échecs.""" results = [probe(name, fn) for name, fn in probes] ok, failed = write_health(get_conn, service, results) if failed: msg = f"[health:{service}] DÉGRADÉ — " + " | ".join(failed) log.warning(msg) if log_event: try: log_event("warning", msg) except Exception: # noqa: BLE001, S110 - journaliser un échec de # journalisation n'apporte rien et risquerait une récursion. pass else: log.info(f"[health:{service}] toutes les sondes sont au vert") return ok def maybe_run(get_conn, service, probes, interval_s=DEFAULT_INTERVAL_S, log_event=None, force=False): """Point d'entrée depuis une boucle de service : ne contrôle qu'une fois par intervalle.""" global _last_run now = time.monotonic() if not force and _last_run and (now - _last_run) < interval_s: return None _last_run = now return run_checks(get_conn, service, probes, log_event=log_event)