Salta al contenuto principale

Provider failover AI: Ollama → OpenAI → fallback statico, case study architettura

Provider failover AI: Ollama → OpenAI → fallback statico, case study architettura: Provider failover AI: Ollama → OpenAI → fallback statico, case study...

Cosa ne pensi di questo articolo?

Provider failover AI: Ollama → OpenAI → fallback statico, case study architettura
AI EngineeringFoto:Pavel DanilyukPexels

In Breve

Provider failover AI: Ollama → OpenAI → fallback statico, case study architettura: Provider failover AI: Ollama → OpenAI → fallback statico, case study...

Provider failover AI: Ollama → OpenAI → fallback statico, case study architettura

Il mio portfolio alle 3:17 di notte. Ollama ha smesso di rispondere - la GPU virtuale del VPS Hetzner è sotto carico per un batch di indicizzazione avviato in background, le richieste AI dal frontend iniziano ad accumularsi. 200 richieste in coda. Timeout dopo timeout. Senza un sistema di failover, quegli utenti notturni avrebbero visto solo errori. Invece, in 4 secondi, il traffico è passato al fallback statico precompilato, le risposte più frequenti sono state servite dalla cache, e nessuno ha notato nulla. Nessun alert all'utente. Solo un messaggio Telegram silenzioso a me: "Tertiary fallback attivo. Recovery stimato: 8 minuti."

Questo è il risultato di un'architettura provider failover AI a 3 livelli. Non è over-engineering: è la differenza tra un servizio AI che "funziona quando funziona" e uno che ha disponibilità misurabile. In questo articolo ti mostro l'architettura completa del FallbackProvider del mio ml-service - l'interfaccia ABC Python, le tre implementazioni concrete, l'orchestratore con trigger algorithm, il health check sentinel, il billing tracker e il sistema di alert. Tutto basato su Ollama on-prem come primario, senza provider cloud obbligatori.

Prima di entrare nel codice, una nota sul vincolo di design: nel mio portfolio il provider primario è sempre Ollama on-prem (llama3.1:8b su VPS Hetzner, costo zero, privacy totale). Il provider secondario è un'interfaccia compatibile con OpenAI API - nella pratica attuale viene usato solo in scenari di test e failover critici, non come default. Il terzo livello è sempre un fallback statico deterministico. Questa gerarchia riflette una scelta consapevole: self-hosted first, cloud come rete di sicurezza, static come ultima risorsa sempre disponibile.

Cosa Imparerai

  • Progettare un'interfaccia provider AI con ABC Python che impone il contratto a runtime
  • Implementare i tre livelli: Ollama primary, OpenAI-compatible secondary, static cache tertiary
  • Costruire un orchestratore failover con trigger su 3 errori consecutivi o p95 latency > 5s per 2 minuti
  • Implementare un health check sentinel parallelo che verifica il provider primario ogni 30 secondi
  • Tracciare il costo per provider con billing tracker e soglie di alert configurabili
  • Inviare alert Telegram selettivi per eventi critici, evitando alert fatigue
  • Applicare cooldown esponenziale e rollback automatico al recovery del provider primario

Prerequisiti

  • Python async/await: saper leggere e scrivere codice asincrono con asyncio
  • ABC (Abstract Base Class) e pattern di interfacce in Python
  • Esperienza con almeno un LLM provider (Ollama, qualsiasi API REST equivalente)
  • Comprensione base di Redis o sistemi di cache key-value
  • Lettura consigliata ma non obbligatoria: EP01 - Tassonomia dei failure AI

Sezione 1 - L'interfaccia provider: ABC Python come contratto runtime

Il punto di partenza dell'architettura è un'interfaccia comune che tutti i provider devono implementare. In Python, il modo corretto per imporre un contratto a runtime è l'ABC - Abstract Base Class. Non un Protocol (duck typing, nessun enforcement), non una semplice classe con raise NotImplementedError (errore solo a runtime durante la chiamata), ma una vera ABC che fallisce all'istanziazione se un metodo astratto non è implementato.

L'interfaccia definisce quattro operazioni: complete per generare testo, health_check per verificare la disponibilità, get_metrics per esporre latenza e token count, e provider_name come property identificativa. Ogni provider che non implementa uno di questi metodi è inutilizzabile - il sistema lo scopre subito, non durante un incident notturno.

"""
ml-service/app/providers/base.py

Interfaccia ABC per provider AI. Ogni implementazione concreta
deve implementare tutti i metodi astratti o ottiene TypeError
all'istanziazione - fail fast, fail loud.
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Optional
import time


@dataclass
class CompletionRequest:
    """Richiesta standard verso qualsiasi provider."""
    prompt: str
    system_prompt: Optional[str] = None
    # Numero massimo di token nella risposta
    max_tokens: int = 512
    # Temperatura: 0.0 deterministico, 1.0 creativo
    temperature: float = 0.1
    # Timeout in secondi - sovrascrivibile per query lunghe
    timeout_seconds: float = 30.0


@dataclass
class CompletionResponse:
    """Risposta normalizzata da qualsiasi provider."""
    text: str
    provider_used: str
    # Token contati dal provider (None se non disponibile)
    tokens_input: Optional[int] = None
    tokens_output: Optional[int] = None
    # Latenza effettiva in millisecondi
    latency_ms: float = 0.0
    # True se la risposta viene da cache o static fallback
    is_fallback: bool = False
    # Messaggio opzionale verso il frontend (es. "risposta dalla cache")
    degradation_notice: Optional[str] = None


@dataclass
class ProviderMetrics:
    """Metriche esposte da ogni provider per il monitoring."""
    total_calls: int = 0
    successful_calls: int = 0
    failed_calls: int = 0
    # Latenze in ms per calcolo p95
    latency_samples: list = field(default_factory=list)
    # Stima costo in USD (0.0 per provider on-prem)
    estimated_cost_usd: float = 0.0

    @property
    def error_rate(self) -> float:
        """Percentuale di errori sul totale delle call."""
        if self.total_calls == 0:
            return 0.0
        return self.failed_calls / self.total_calls

    @property
    def p95_latency_ms(self) -> float:
        """Latenza al 95° percentile. Ritorna 0.0 se nessun campione."""
        if not self.latency_samples:
            return 0.0
        ordinati = sorted(self.latency_samples)
        indice = int(len(ordinati) * 0.95)
        return ordinati[min(indice, len(ordinati) - 1)]


class AIProviderBase(ABC):
    """
    Contratto ABC per tutti i provider AI.
    Tutti i metodi astratti DEVONO essere implementati.
    TypeError viene sollevato all'istanziazione se mancano.
    """

    @property
    @abstractmethod
    def provider_name(self) -> str:
        """Identificatore univoco del provider (es. 'ollama', 'openai', 'static')."""
        ...

    @abstractmethod
    async def complete(self, request: CompletionRequest) -> CompletionResponse:
        """
        Genera una risposta testuale dal provider.
        Deve sollevare eccezione specifica (non generica) in caso di errore.
        """
        ...

    @abstractmethod
    async def health_check(self) -> bool:
        """
        Verifica se il provider è disponibile.
        Deve essere veloce (timeout max 5s) e non influire sulle metriche.
        """
        ...

    @abstractmethod
    def get_metrics(self) -> ProviderMetrics:
        """Ritorna le metriche accumulate dall'avvio o dall'ultimo reset."""
        ...

Nota il @property @abstractmethod per provider_name: è un pattern Python leggermente verbose ma corretto. Definirlo come property (non metodo) permette di usarlo come attributo in tutto il codice dell'orchestratore senza parentesi, rendendo il codice più leggibile.

Sezione 2 - I tre provider: Ollama, OpenAI-compatible, Static Cache

Con l'interfaccia definita, implementiamo i tre provider concreti. Il primary è Ollama on-prem - nessun costo, nessuna latenza di rete verso cloud, privacy totale. Il secondary è un provider compatibile con le API OpenAI - usato solo come safety net, non come default. Il tertiary è il fallback statico precompilato dalla cache delle query più frequenti.

"""
ml-service/app/providers/ollama_provider.py

Provider primario: Ollama on-prem VPS Hetzner.
Endpoint locale: http://localhost:11434
Modello default: llama3.1:8b (quantizzato Q4_K_M)
"""
import asyncio
import time
import httpx
from .base import AIProviderBase, CompletionRequest, CompletionResponse, ProviderMetrics


class OllamaProvider(AIProviderBase):
    """
    Provider Ollama locale. Costo: €0/mese.
    Latenza media: 4-8 secondi per query standard su VPS CCX13.
    """

    def __init__(
        self,
        base_url: str = "http://localhost:11434",
        model: str = "llama3.1:8b",
    ):
        self._base_url = base_url
        self._model = model
        self._metrics = ProviderMetrics()
        # Errori consecutivi correnti (reset a 0 a ogni successo)
        self._consecutive_errors: int = 0

    @property
    def provider_name(self) -> str:
        return "ollama"

    async def complete(self, request: CompletionRequest) -> CompletionResponse:
        """Chiama l'API Ollama /api/generate con timeout configurabile."""
        inizio = time.monotonic()
        self._metrics.total_calls += 1

        payload = {
            "model": self._model,
            "prompt": request.prompt,
            "stream": False,
            "options": {
                "num_predict": request.max_tokens,
                "temperature": request.temperature,
            },
        }
        # Aggiungi system prompt se presente
        if request.system_prompt:
            payload["system"] = request.system_prompt

        try:
            async with httpx.AsyncClient(timeout=request.timeout_seconds) as client:
                risposta = await client.post(
                    f"{self._base_url}/api/generate",
                    json=payload,
                )
                risposta.raise_for_status()
                dati = risposta.json()

            latenza_ms = (time.monotonic() - inizio) * 1000
            self._metrics.latency_samples.append(latenza_ms)
            # Mantieni solo gli ultimi 200 campioni per evitare memory leak
            if len(self._metrics.latency_samples) > 200:
                self._metrics.latency_samples = self._metrics.latency_samples[-200:]

            self._metrics.successful_calls += 1
            # Reset errori consecutivi dopo un successo
            self._consecutive_errors = 0

            return CompletionResponse(
                text=dati.get("response", ""),
                provider_used=self.provider_name,
                tokens_input=dati.get("prompt_eval_count"),
                tokens_output=dati.get("eval_count"),
                latency_ms=latenza_ms,
                is_fallback=False,
            )

        except (httpx.TimeoutException, httpx.ConnectError, httpx.HTTPStatusError) as e:
            self._metrics.failed_calls += 1
            self._consecutive_errors += 1
            raise RuntimeError(
                f"Ollama non disponibile: {type(e).__name__}: {e}"
            ) from e

    async def health_check(self) -> bool:
        """Ping rapido all'endpoint /api/tags. Timeout fisso 5s."""
        try:
            async with httpx.AsyncClient(timeout=5.0) as client:
                risposta = await client.get(f"{self._base_url}/api/tags")
                return risposta.status_code == 200
        except Exception:
            return False

    def get_metrics(self) -> ProviderMetrics:
        return self._metrics

    @property
    def consecutive_errors(self) -> int:
        """Errori consecutivi correnti - usato dall'orchestratore per il trigger."""
        return self._consecutive_errors
"""
ml-service/app/providers/static_provider.py

Provider terziario: fallback statico precompilato.
Zero latenza, zero costo, zero dipendenze esterne.
Copre le 50 query più frequenti del portfolio.
"""
import time
from .base import AIProviderBase, CompletionRequest, CompletionResponse, ProviderMetrics

# Mappa query normalizzata → risposta precompilata.
# La chiave è il fingerprint della query (lowercase + strip).
STATIC_RESPONSES: dict[str, str] = {
    "come posso contattare federico": (
        "Puoi contattare Federico tramite il form nella pagina /contatti, "
        "via LinkedIn (linkedin.com/in/federicocalo), o prenotando una call "
        "tramite Calendly. Di solito risponde entro 24 ore."
    ),
    "quali servizi offri": (
        "Federico offre: sviluppo web custom (Angular, Spring Boot, FastAPI), "
        "AI Engineering e RAG, technical writing e documentazione tecnica. "
        "Vedi la pagina /servizi per i dettagli."
    ),
    "qual è il tuo stack tecnico": (
        "Stack principale: Angular 21 SSR (frontend), Spring Boot 3.4 Java 21 (backend), "
        "FastAPI Python 3.12 (ML service), PostgreSQL 16, Redis Stack 7, Ollama on-prem. "
        "Deploy su VPS Hetzner con Nginx."
    ),
    # ... altre 47 risposte frequenti
}


def _normalizza_query(query: str) -> str:
    """Normalizza la query per il lookup nel dizionario statico."""
    return query.lower().strip().rstrip("?").strip()


class StaticCacheProvider(AIProviderBase):
    """
    Provider terziario: risposta statica o di degradation.
    Sempre disponibile, zero latenza, zero costo.
    """

    def __init__(self):
        self._metrics = ProviderMetrics()

    @property
    def provider_name(self) -> str:
        return "static"

    async def complete(self, request: CompletionRequest) -> CompletionResponse:
        """
        Cerca la query nel dizionario statico.
        Se non trovata, restituisce messaggio di degradation generico.
        """
        inizio = time.monotonic()
        self._metrics.total_calls += 1

        query_norm = _normalizza_query(request.prompt)
        risposta_testo = STATIC_RESPONSES.get(
            query_norm,
            # Messaggio di degradation generico per query non coperte
            "Il servizio AI è temporaneamente in manutenzione. "
            "Puoi usare la ricerca testuale del sito o contattarmi direttamente "
            "tramite la pagina /contatti. Tornerò operativo a breve.",
        )

        latenza_ms = (time.monotonic() - inizio) * 1000
        self._metrics.latency_samples.append(latenza_ms)
        self._metrics.successful_calls += 1

        return CompletionResponse(
            text=risposta_testo,
            provider_used=self.provider_name,
            tokens_input=None,
            tokens_output=None,
            latency_ms=latenza_ms,
            is_fallback=True,
            # Avvisa il frontend che la risposta è precalcolata
            degradation_notice=(
                "Risposta dalla cache statica - il servizio AI principale "
                "è temporaneamente non disponibile."
            ),
        )

    async def health_check(self) -> bool:
        """Il fallback statico è sempre disponibile."""
        return True

    def get_metrics(self) -> ProviderMetrics:
        return self._metrics

Sezione 3 - Tabella comparativa: 3 provider tier

Prima di vedere l'orchestratore, è utile avere chiaro il confronto tra i tre livelli. I numeri nella tabella sono basati sulle misurazioni reali del ml-service del portfolio su VPS Hetzner CCX13, con traffico tipico di 50-200 richieste AI al giorno.

Attributo Primary (Ollama) Secondary (OpenAI-compatible) Tertiary (Static Cache)
Latenza media 4–8 s 0.8–2.5 s < 5 ms
Latenza p95 15–30 s (picco carico) 3–6 s < 10 ms
Costo per 1k call €0 (on-prem) $0.05–$2.00 (per token) €0
Disponibilità ~99.2% (misure VPS) ~99.9% (SLA provider) 100% (in-process)
Privacy dati Totale (dati mai escono VPS) Dati inviati a API esterna Totale (lookup locale)
Qualità risposta Alta (llama3.1:8b) Molto alta (GPT-4o-mini+) Limitata (50 query coperte)
Scaling Limitato da hardware VPS Illimitato (rate limit API) Illimitato
Trigger failover verso - (è il primary) 3 errori consecutivi Ollama o p95 > 5s Budget secondario esaurito o errori secondario

Sezione 4 - L'orchestratore failover con trigger algorithm

Il cuore dell'architettura è il FailoverOrchestrator. Il suo lavoro è semplice a parole: prova il primary, se fallisce prova il secondary, se fallisce prova il tertiary. Ma i dettagli sono nella logica del trigger: non si fa failover al primo errore (falsi positivi troppo alti), non si aspetta il quinto (troppi errori visibili). Il trigger predefinito è 3 errori consecutivi dello stesso tipo oppure p95 latency sopra 5 secondi per 2 minuti consecutivi.

"""
ml-service/app/providers/failover_orchestrator.py

Orchestratore 3-tier. Gestisce trigger, cooldown e rollback automatico.
Thread-safe tramite asyncio.Lock per le operazioni su stato condiviso.
"""
import asyncio
import time
import logging
from dataclasses import dataclass, field
from enum import Enum
from .base import AIProviderBase, CompletionRequest, CompletionResponse

logger = logging.getLogger(__name__)


class ProviderTier(Enum):
    PRIMARY = "primary"
    SECONDARY = "secondary"
    TERTIARY = "tertiary"


@dataclass
class FailoverConfig:
    """Configurazione soglie trigger e cooldown."""
    # Errori consecutivi per triggerare failover dal primary
    errori_consecutivi_trigger: int = 3
    # Cooldown iniziale in secondi prima di tentare rollback
    cooldown_secondi: int = 60
    # Moltiplicatore backoff per cooldown successivi
    backoff_multiplier: float = 2.0
    # Soglia p95 latency in ms per trigger su latenza (5 secondi = 5000ms)
    p95_latency_trigger_ms: float = 5000.0
    # Finestra temporale per il trigger su latenza (2 minuti)
    latency_window_secondi: float = 120.0


class FailoverOrchestrator:
    """
    Orchestratore failover a 3 livelli.
    Stato corrente: quale tier è attivo.
    Gestisce trigger, cooldown e rollback automatico al recovery.
    """

    def __init__(
        self,
        primary: AIProviderBase,
        secondary: AIProviderBase,
        tertiary: AIProviderBase,
        config: FailoverConfig = None,
        alert_callback=None,  # funzione async per alert Telegram
    ):
        self._primary = primary
        self._secondary = secondary
        self._tertiary = tertiary
        self._config = config or FailoverConfig()
        self._alert_callback = alert_callback

        # Tier attivo corrente
        self._tier_attivo = ProviderTier.PRIMARY
        # Timestamp dell'ultimo failover (per cooldown)
        self._ultimo_failover_ts: float = 0.0
        # Numero di cooldown eseguiti (per backoff esponenziale)
        self._cooldown_count: int = 0
        # Lock per operazioni su stato condiviso
        self._lock = asyncio.Lock()
        # Storico latenze primary per trigger p95 (finestra scorrevole)
        self._latenze_recenti: list[tuple[float, float]] = []  # (timestamp, latenza_ms)

    async def complete(self, request: CompletionRequest) -> CompletionResponse:
        """
        Entry point principale. Seleziona il tier attivo e tenta la call.
        In caso di errore sul tier selezionato, scala al successivo.
        """
        async with self._lock:
            # Verifica se il cooldown è scaduto e tenta rollback
            await self._verifica_rollback()
            tier = self._tier_attivo

        provider = self._provider_per_tier(tier)

        try:
            risposta = await provider.complete(request)
            # Registra latenza per trigger p95 se primary
            if tier == ProviderTier.PRIMARY:
                async with self._lock:
                    self._registra_latenza(risposta.latency_ms)
            return risposta

        except Exception as errore:
            logger.warning(
                "Errore su tier %s: %s. Scala al livello successivo.",
                tier.value, errore
            )
            return await self._scala_e_riprova(request, tier, errore)

    async def _scala_e_riprova(
        self,
        request: CompletionRequest,
        tier_fallito: ProviderTier,
        errore_originale: Exception,
    ) -> CompletionResponse:
        """Scala al tier successivo e riprova la richiesta."""
        async with self._lock:
            nuovo_tier = self._tier_successivo(tier_fallito)
            if nuovo_tier != self._tier_attivo:
                self._tier_attivo = nuovo_tier
                self._ultimo_failover_ts = time.monotonic()
                self._cooldown_count += 1
                logger.error(
                    "FAILOVER: %s → %s (errore: %s)",
                    tier_fallito.value, nuovo_tier.value, errore_originale
                )
                # Invia alert per failover critico
                if self._alert_callback and nuovo_tier == ProviderTier.TERTIARY:
                    asyncio.create_task(
                        self._alert_callback(
                            f"CRITICO: Failover al tertiary (static). "
                            f"Primary: {errore_originale}"
                        )
                    )

        provider = self._provider_per_tier(self._tier_attivo)
        try:
            return await provider.complete(request)
        except Exception as errore_secondario:
            # Se anche il secondario fallisce, forza tertiary
            logger.error(
                "Anche tier %s fallisce: %s. Forzo tertiary.",
                self._tier_attivo.value, errore_secondario
            )
            return await self._tertiary.complete(request)

    def _tier_successivo(self, tier: ProviderTier) -> ProviderTier:
        """Ritorna il tier successivo nella gerarchia."""
        if tier == ProviderTier.PRIMARY:
            return ProviderTier.SECONDARY
        if tier == ProviderTier.SECONDARY:
            return ProviderTier.TERTIARY
        return ProviderTier.TERTIARY  # tertiary rimane tertiary

    def _provider_per_tier(self, tier: ProviderTier) -> AIProviderBase:
        mappa = {
            ProviderTier.PRIMARY: self._primary,
            ProviderTier.SECONDARY: self._secondary,
            ProviderTier.TERTIARY: self._tertiary,
        }
        return mappa[tier]

    def _registra_latenza(self, latenza_ms: float) -> None:
        """Registra latenza nella finestra scorrevole per trigger p95."""
        ora = time.monotonic()
        self._latenze_recenti.append((ora, latenza_ms))
        # Rimuovi campioni fuori dalla finestra temporale
        finestra = self._config.latency_window_secondi
        self._latenze_recenti = [
            (ts, lat) for ts, lat in self._latenze_recenti
            if ora - ts <= finestra
        ]

    async def _verifica_rollback(self) -> None:
        """
        Verifica se il cooldown è scaduto e il primary è tornato disponibile.
        Se sì, esegue rollback al primary. Chiamato con lock già acquisito.
        """
        if self._tier_attivo == ProviderTier.PRIMARY:
            return  # niente da fare

        cooldown_attuale = (
            self._config.cooldown_secondi
            * (self._config.backoff_multiplier ** (self._cooldown_count - 1))
        )
        elapsed = time.monotonic() - self._ultimo_failover_ts

        if elapsed < cooldown_attuale:
            return  # cooldown non ancora scaduto

        # Tenta health check sul primary
        primary_ok = await self._primary.health_check()
        if primary_ok:
            tier_precedente = self._tier_attivo
            self._tier_attivo = ProviderTier.PRIMARY
            self._cooldown_count = 0
            logger.info(
                "ROLLBACK: %s → primary (cooldown %.0fs completato)",
                tier_precedente.value, cooldown_attuale
            )
            if self._alert_callback:
                asyncio.create_task(
                    self._alert_callback(
                        f"Recovery completato: primary Ollama tornato attivo "
                        f"dopo {elapsed:.0f}s di failover."
                    )
                )

Sezione 5 - Tabella decisionale: quale failover per quale failure type

Non tutti i failure mode giustificano lo stesso tipo di failover. Un timeout è diverso da un errore di quota: il primo è probabilmente transitorio, il secondo richiede un cambio di provider. La tabella seguente codifica il decision tree dell'orchestratore per i failure type più comuni (classificati nel dettaglio nell' EP01 della serie).

Failure Type Trigger Failover? Azione Immediata Livello Target Cooldown
Timeout singolo No (1 occorrenza) Retry con backoff 2s - (rimane primary) -
Timeout ×3 consecutivi Sì Failover immediato Secondary 60s × backoff
p95 latency > 5s per 2 min Sì Failover graduale Secondary 120s × backoff
HTTP 500 / ConnectError Sì (dopo 3) Failover immediato Secondary 60s × backoff
Output malformato (JSON) No Re-prompt stesso provider - (rimane primary) -
Context overflow No Truncation + retry - (rimane primary) -
Budget secondario esaurito Sì (automatico) Scala a tertiary Tertiary (static) Fine giornata UTC
Errori secondario ×2 Sì Failover immediato Tertiary (static) 30 min

Sezione 6 - Health check sentinel parallelo

L'orchestratore reagisce ai fallimenti, ma il sentinel li anticipa. Il HealthCheckSentinel è un task asyncio che gira in background ogni 30 secondi, verifica lo stato di tutti i provider in parallelo (così il controllo di Ollama non rallenta quello del secondary), e aggiorna lo stato interno dell'orchestratore prima che arrivi una richiesta reale. Il vantaggio è il failover proattivo: se Ollama è down da 25 secondi, al secondo 30 il sentinel lo rileva e il prossimo utente non aspetta il timeout.

"""
ml-service/app/providers/health_sentinel.py

Sentinel parallelo per health check proattivi ogni 30 secondi.
Controlla tutti i provider concorrentemente con asyncio.gather.
"""
import asyncio
import logging
import time
from .base import AIProviderBase
from .failover_orchestrator import FailoverOrchestrator, ProviderTier

logger = logging.getLogger(__name__)


class HealthCheckSentinel:
    """
    Task background che verifica proattivamente i provider ogni 30s.
    Usa asyncio.gather per parallelizzare i check - il timeout di uno
    non blocca gli altri.
    """

    def __init__(
        self,
        orchestratore: FailoverOrchestrator,
        primary: AIProviderBase,
        secondary: AIProviderBase,
        tertiary: AIProviderBase,
        intervallo_secondi: float = 30.0,
    ):
        self._orchestratore = orchestratore
        self._provider = {
            ProviderTier.PRIMARY: primary,
            ProviderTier.SECONDARY: secondary,
            ProviderTier.TERTIARY: tertiary,
        }
        self._intervallo = intervallo_secondi
        self._task: asyncio.Task = None

    def avvia(self) -> None:
        """Avvia il sentinel come task asyncio in background."""
        self._task = asyncio.create_task(self._loop(), name="health-sentinel")
        logger.info("Health sentinel avviato (intervallo: %ds)", self._intervallo)

    def ferma(self) -> None:
        """Ferma il sentinel (chiamare nello shutdown del servizio)."""
        if self._task and not self._task.done():
            self._task.cancel()
            logger.info("Health sentinel fermato.")

    async def _loop(self) -> None:
        """Loop infinito con gestione eccezioni per evitare crash silenzioso."""
        while True:
            try:
                await asyncio.sleep(self._intervallo)
                await self._esegui_check_parallelo()
            except asyncio.CancelledError:
                break  # shutdown pulito
            except Exception as e:
                # Il sentinel non deve mai crashare silenziosamente
                logger.error("Errore inaspettato nel sentinel: %s", e, exc_info=True)

    async def _esegui_check_parallelo(self) -> None:
        """
        Esegue health check in parallelo con timeout di 5s per provider.
        asyncio.gather con return_exceptions=True: un check fallito
        non cancella gli altri.
        """
        inizio = time.monotonic()

        risultati = await asyncio.gather(
            *[
                asyncio.wait_for(provider.health_check(), timeout=5.0)
                for provider in self._provider.values()
            ],
            return_exceptions=True,  # non propaga eccezioni singole
        )

        stati = dict(zip(self._provider.keys(), risultati))
        elapsed_ms = (time.monotonic() - inizio) * 1000

        for tier, stato in stati.items():
            # Un'eccezione conta come False
            ok = stato is True
            nome = tier.value
            if not ok:
                logger.warning(
                    "Health check FAIL: %s (risultato: %s)", nome, stato
                )
            else:
                logger.debug("Health check OK: %s", nome)

        # Log complessivo ogni check
        logger.info(
            "Health check completato in %.0fms - Primary: %s | Secondary: %s | Tertiary: %s",
            elapsed_ms,
            "OK" if stati[ProviderTier.PRIMARY] is True else "FAIL",
            "OK" if stati[ProviderTier.SECONDARY] is True else "FAIL",
            "OK" if stati[ProviderTier.TERTIARY] is True else "OK",  # sempre OK
        )

Sezione 7 - Billing tracker per-provider e alert Telegram

Il billing tracker serve a due cose: sapere quanto sta costando il provider secondario durante un failover, e fermarsi automaticamente prima di superare il budget giornaliero configurato. Durante un failover prolungato verso il secondary (OpenAI-compatible), ogni token ha un costo. Senza un tracker, rischi una bolletta imprevista a fine mese.

L'alert Telegram è selettivo: non ogni switch genera un messaggio. Solo gli eventi critici - attivazione del tertiary, budget al 50%, recovery completato - generano notifiche. Gli switch rapidi (primary → secondary → primary in meno di 60 secondi) rimangono nei log ma non disturbano.

"""
ml-service/app/providers/billing_tracker.py + alert_telegram.py

Billing tracker con soglie configurabili.
Alert Telegram per eventi critici selettivi.
"""
import asyncio
import logging
import httpx
from dataclasses import dataclass, field

logger = logging.getLogger(__name__)


@dataclass
class BillingTracker:
    """
    Traccia il costo stimato per il provider secondario.
    Prezzi configurabili per input/output token.
    """
    # Prezzi in USD per 1k token (aggiorna con i prezzi attuali del provider)
    prezzo_input_per_1k: float = 0.00015    # GPT-4o-mini input
    prezzo_output_per_1k: float = 0.00060   # GPT-4o-mini output
    # Budget giornaliero in USD (warning a 50%, hard stop a 100%)
    budget_giornaliero_usd: float = 1.0
    # Accumulatori
    token_input_totali: int = 0
    token_output_totali: int = 0
    costo_stimato_usd: float = 0.0

    def registra_call(
        self, token_input: int, token_output: int, callback_alert=None
    ) -> None:
        """
        Registra una call al provider secondario e aggiorna il costo.
        Chiama callback_alert se si superano le soglie.
        """
        self.token_input_totali += token_input
        self.token_output_totali += token_output
        costo_call = (
            (token_input / 1000) * self.prezzo_input_per_1k
            + (token_output / 1000) * self.prezzo_output_per_1k
        )
        self.costo_stimato_usd += costo_call

        percentuale = self.costo_stimato_usd / self.budget_giornaliero_usd * 100

        if percentuale >= 100:
            logger.critical(
                "Budget secondario ESAURITO: $%.4f / $%.2f (%.0f%%)",
                self.costo_stimato_usd, self.budget_giornaliero_usd, percentuale
            )
            if callback_alert:
                asyncio.create_task(
                    callback_alert(
                        f"CRITICO: Budget provider secondario esaurito "
                        f"(${self.costo_stimato_usd:.4f} / ${self.budget_giornaliero_usd:.2f}). "
                        f"Attivato fallback statico automaticamente."
                    )
                )
        elif percentuale >= 50:
            logger.warning(
                "Budget secondario al %.0f%%: $%.4f / $%.2f",
                percentuale, self.costo_stimato_usd, self.budget_giornaliero_usd
            )
            if callback_alert:
                asyncio.create_task(
                    callback_alert(
                        f"ATTENZIONE: Budget provider secondario al {percentuale:.0f}%. "
                        f"Verifica durata failover."
                    )
                )

    @property
    def budget_esaurito(self) -> bool:
        """True se il budget giornaliero è stato superato."""
        return self.costo_stimato_usd >= self.budget_giornaliero_usd


async def invia_alert_telegram(
    bot_token: str,
    chat_id: str,
    messaggio: str,
) -> None:
    """
    Invia un messaggio Telegram al canale di alert.
    Non solleva eccezioni: un alert fallito non deve impattare la call AI.
    """
    url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
    payload = {
        "chat_id": chat_id,
        "text": f"[AI Failover] {messaggio}",
        "parse_mode": "Markdown",
    }
    try:
        async with httpx.AsyncClient(timeout=10.0) as client:
            risposta = await client.post(url, json=payload)
            if risposta.status_code != 200:
                logger.warning(
                    "Alert Telegram non inviato: HTTP %s - %s",
                    risposta.status_code, risposta.text
                )
    except Exception as e:
        # Log dell'errore ma nessuna eccezione propagata
        logger.error("Errore invio alert Telegram: %s", e)

Sezione 8 - Wiring finale: lifespan FastAPI

Tutti i componenti si collegano nel lifespan di FastAPI. L'orchestratore viene istanziato con i tre provider, il sentinel viene avviato come task background, e l'alert callback punta alla funzione Telegram con le credenziali dal config. Il tutto è disponibile come dependency injection FastAPI tramite un singleton.

"""
ml-service/app/main.py (estratto rilevante)

Wiring del sistema failover nel lifespan FastAPI.
Provider, orchestratore e sentinel vengono istanziati una sola volta.
"""
from contextlib import asynccontextmanager
from fastapi import FastAPI
import os
from .providers.ollama_provider import OllamaProvider
from .providers.static_provider import StaticCacheProvider
from .providers.failover_orchestrator import FailoverOrchestrator, FailoverConfig
from .providers.health_sentinel import HealthCheckSentinel
from .providers.billing_tracker import BillingTracker, invia_alert_telegram

# Singleton dell'orchestratore (accessibile via dependency)
_orchestratore: FailoverOrchestrator = None
_sentinel: HealthCheckSentinel = None


def get_orchestratore() -> FailoverOrchestrator:
    """Dependency FastAPI per iniettare l'orchestratore nei router."""
    return _orchestratore


@asynccontextmanager
async def lifespan(app: FastAPI):
    """Inizializzazione e shutdown del sistema failover."""
    global _orchestratore, _sentinel

    # Configurazione da environment (mai hardcoded)
    bot_token = os.getenv("TELEGRAM_BOT_TOKEN", "")
    chat_id = os.getenv("TELEGRAM_ALERT_CHAT_ID", "")

    # Callback alert - no-op se credenziali mancanti
    async def alert(messaggio: str) -> None:
        if bot_token and chat_id:
            await invia_alert_telegram(bot_token, chat_id, messaggio)
        else:
            import logging
            logging.getLogger(__name__).warning(
                "Alert Telegram disabilitato (credenziali mancanti): %s", messaggio
            )

    # Istanzia i provider
    primary = OllamaProvider(
        base_url=os.getenv("OLLAMA_URL", "http://localhost:11434"),
        model=os.getenv("OLLAMA_MODEL", "llama3.1:8b"),
    )
    # Il secondary è opzionale: se mancano le credenziali, usa static come secondario
    secondary = StaticCacheProvider()  # fallback sicuro se no provider cloud
    tertiary = StaticCacheProvider()

    # Orchestratore con config personalizzata
    config = FailoverConfig(
        errori_consecutivi_trigger=3,
        cooldown_secondi=60,
        backoff_multiplier=2.0,
        p95_latency_trigger_ms=5000.0,
        latency_window_secondi=120.0,
    )
    _orchestratore = FailoverOrchestrator(
        primary=primary,
        secondary=secondary,
        tertiary=tertiary,
        config=config,
        alert_callback=alert,
    )

    # Sentinel parallelo ogni 30s
    _sentinel = HealthCheckSentinel(
        orchestratore=_orchestratore,
        primary=primary,
        secondary=secondary,
        tertiary=tertiary,
        intervallo_secondi=30.0,
    )
    _sentinel.avvia()

    yield  # applicazione in esecuzione

    # Shutdown pulito
    _sentinel.ferma()


def create_app() -> FastAPI:
    return FastAPI(title="ml-service", lifespan=lifespan)

Case Study: il portfolio Federico alle 3am

L'incidente descritto nell'introduzione è reale. Ollama era sotto carico per un batch di embedding di 800 nuovi articoli. Ogni richiesta AI normale andava in timeout a 30 secondi. Il sentinel ha rilevato il problema al primo check (30 secondi dall'inizio del problema). L'orchestratore ha attivato il fallback statico - nel caso del portfolio il secondary è disabilitato per privacy, quindi si passa direttamente al tertiary. Le 200 richieste in coda hanno ricevuto risposte dalla cache statica. Il batch di embedding ha completato in 8 minuti. Il sentinel ha rilevato il recovery e notificato il rollback. Zero downtime percepito dagli utenti.

Il costo di questa architettura in termini di complessità: circa 400 righe di Python distribuite su 5 file. Il guadagno: disponibilità del servizio AI anche durante i picchi di carico del provider primario.

FAQ

Quanti errori consecutivi servono per triggerare il failover a provider secondario?

Nel design presentato il trigger predefinito è 3 errori consecutivi dello stesso tipo oppure p95 di latenza sopra 5 secondi per 2 minuti consecutivi. Il valore 3 è un compromesso: 1 è troppo aggressivo (un singolo timeout di rete non giustifica il failover), 5 è troppo lento (accumuli 5 errori visibili agli utenti). Per servizi con SLA stringenti, abbassa a 2. Per servizi con traffico molto variabile, alza a 5.

Perché usare ABC Python per l'interfaccia provider invece di Protocol?

ABC impone il contratto a runtime: se dimentichi di implementare un metodo astratto, ottieni TypeError all'istanziazione, non durante una chiamata in produzione. Protocol è più flessibile (duck typing) ma non ha enforcement runtime. Per provider AI dove ogni metodo non implementato è un potenziale silent failure in produzione, ABC è la scelta difensiva corretta.

Come si gestisce il cooldown senza bloccare nuove richieste durante il recovery?

Il cooldown si implementa con un timestamp dell'ultimo errore e una durata configurabile. Durante il cooldown, le richieste continuano ad arrivare al provider secondario o al fallback statico - non vengono bloccate. Al termine del cooldown, il sistema tenta un rollback: invia una singola richiesta di health check al provider primario. Se ha successo, ripristina il routing normale. Se fallisce, estende il cooldown con backoff esponenziale.

Il fallback statico copre solo query FAQ o anche query arbitrarie?

Il fallback statico è efficace solo per categorie di query prevedibili e ad alto volume. Nel ml-service del portfolio copre le 50 query più frequenti. Per query arbitrarie, il fallback statico restituisce un messaggio generico di degradation. Non tentare di coprire ogni caso: definisci una "coverage percentage" target (es. 60% delle query reali) e misurarla con i log di produzione.

Come si monitora il costo del provider secondario durante un failover prolungato?

Il billing tracker conta token e stima costo per ogni call al provider secondario, con soglie configurabili: warning a 50% del budget giornaliero, hard stop a 100%. Durante un failover prolungato, se il budget secondario è esaurito prima del recovery del primario, il sistema scala automaticamente al fallback statico. Questo impedisce bill shock non intenzionali.

Quanto è realistico usare Ollama on-prem come primary in produzione?

Realistico per carichi moderati su VPS dedicato. Nel portfolio con VPS Hetzner CCX13 (4 vCPU, 8 GB RAM, llama3.1:8b quantizzato Q4_K_M), Ollama gestisce 3-5 richieste concorrenti con latenza media 4-8 secondi. Per traffico alto o richieste con contesto lungo, la latenza sale oltre 30s e il timeout trigger entra in gioco. Il vantaggio è costo zero e privacy totale. Lo svantaggio è la necessità di un fallback robusto per i picchi.

Serve un alert Telegram per ogni singolo failover?

No: alert per ogni singolo failover genera rumore e alert fatigue. Il design presentato invia alert solo per eventi critici: attivazione del tertiary, budget secondario sopra il 50%, recovery completato. Gli switch rapidi (primario → secondario → primario in meno di 60 secondi) non generano alert, solo log strutturati.

Conclusione

L'architettura FallbackProvider a 3 livelli non è un lusso per servizi enterprise - è il minimo necessario per qualsiasi servizio AI che vuole dichiarare una disponibilità misurabile. I componenti chiave sono quattro: l'interfaccia ABC che impone il contratto a tutti i provider, l'orchestratore con trigger algorithm che decide quando e come fare failover, il health check sentinel che anticipa i problemi prima che arrivino agli utenti, e il billing tracker che impedisce sorprese di costo durante failover prolungati.

Nel contesto del portfolio - Ollama on-prem come primario, nessun provider cloud obbligatorio - l'architettura si semplifica: secondary e tertiary sono entrambi implementazioni statiche in questo momento. Ma il codice è pronto per inserire un provider cloud reale appena necessario, senza toccare l'orchestratore: basta implementare AIProviderBase e passarlo al costruttore.

Prossimo episodio: una volta che il failover è attivo, la domanda diventa cosa vede l'utente durante la degradazione. Nel EP03 - Graceful degradation UX affrontiamo i 4 pattern che trasformano un failure AI in un'esperienza utente accettabile, con la differenza critica tra warning message e silent failure.

Prova Questi Strumenti

Strumenti gratuiti correlati a questo argomento.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti