Salta al contenuto principale

Circuit breaker LLM: Resilience4j + smart recovery + half-open probing

Circuit breaker LLM: Resilience4j + smart recovery + half-open probing Il circuit breaker nasce dalla meccanica elettrica: quando la corrente supera la...

Cosa ne pensi di questo articolo?

Circuit breaker LLM: Resilience4j + smart recovery + half-open probing
AI EngineeringFoto:Tara WinsteadPexels

In Breve

Circuit breaker LLM: Resilience4j + smart recovery + half-open probing Il circuit breaker nasce dalla meccanica elettrica: quando la corrente supera la...

Circuit breaker LLM: Resilience4j + smart recovery + half-open probing

Il circuit breaker nasce dalla meccanica elettrica: quando la corrente supera la soglia di sicurezza, il fusibile si apre e interrompe il circuito prima che l'impianto bruci. In software lo stesso pattern protegge i sistemi distribuiti dal cascade failure - quando un servizio down trascina con sé tutti quelli che lo chiamano. Per un LLM in produzione, però, il circuit breaker classico è troppo grossolano. Apre su una percentuale globale di errori, non distingue tra un modello lento e un provider irraggiungibile, non sa che una query da 50k token ha più probabilità di timeout di una da 200 token. Il risultato: falsi positivi che aprono il circuito quando il provider è sano, o falsi negativi che lo lasciano chiuso mentre degrada silenziosamente.

Questo episodio costruisce un circuit breaker smart per LLM, partendo dalla configurazione Resilience4j nel backend Spring Boot che nel portfolio gestisce le chiamate verso il ml-service FastAPI con Ollama on-prem. La struttura multi-dimensionale per provider × model × region evita i falsi positivi. Il half-open probing con richieste canary sintetiche protegge gli utenti reali durante il recovery. Il slow call threshold apre il circuito anche quando il modello risponde - ma in 15 secondi invece di 2. Tutto esportato su Prometheus con alert AlertManager pronti per la produzione.

Nel portfolio backend (Spring Boot 3.4 + Resilience4j 2.x) le chiamate verso ml-service erano originariamente senza circuit breaker. Il primo incidente documentato: Ollama saturato, ml-service in timeout su ogni request, Spring Boot che accumulava thread in attesa fino a esaurimento del connection pool. Quindici minuti di downtime totale per un problema che un circuit breaker avrebbe contenuto in sessanta secondi. Da quel giorno, ogni chiamata cross-service ha il suo CB configurato.

Cosa Imparerai

  • Configurare Resilience4j CircuitBreaker in Spring Boot 3 per chiamate verso servizi LLM
  • Progettare un CB multi-dimensionale per provider × model con istanze separate
  • Implementare half-open probing con richieste canary a costo zero
  • Distinguere failure rate e slow call rate threshold - e perché per LLM contano entrambi
  • Esporre metriche Prometheus per stato CB e configurare alert AlertManager
  • Costruire un circuit breaker Python puro come middleware FastAPI per ml-service
  • Tarare empiricamente i threshold su dati di produzione reali

Prerequisiti

  • Spring Boot 3 e configurazione YAML - devi saper leggere una application.yml
  • Python async/await e FastAPI middleware - per la sezione ml-service
  • Concetto base di circuit breaker (CLOSED/OPEN/HALF_OPEN) - approfondito in EP01
  • Prometheus e PromQL base - per leggere le query di alerting

Sezione 1 - Resilience4j setup: dipendenze e configurazione Spring Boot 3

Resilience4j è la libreria di resilienza de facto per Java/Spring Boot dal 2020, quando Hystrix di Netflix è andato in manutenzione. La versione 2.x - compatibile con Spring Boot 3 e Java 21 - introduce miglioramenti all'integrazione Micrometer che rendono l'export Prometheus praticamente zero-config. Le dipendenze necessarie per il circuit breaker su chiamate HTTP WebClient verso ml-service sono tre: il modulo core Spring Boot 3, il supporto AOP per le annotazioni, e l'actuator per le metriche.

<!-- pom.xml - dipendenze Resilience4j per Spring Boot 3 -->
<dependencies>
    <!-- Circuit breaker + retry + bulkhead + rate limiter -->
    <dependency>
        <groupId>io.github.resilience4j</groupId>
        <artifactId>resilience4j-spring-boot3</artifactId>
        <version>2.2.0</version>
    </dependency>

    <!-- AOP obbligatorio per le annotazioni @CircuitBreaker -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-aop</artifactId>
    </dependency>

    <!-- Actuator + Micrometer per export Prometheus -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>
    <dependency>
        <groupId>io.micrometer</groupId>
        <artifactId>micrometer-registry-prometheus</artifactId>
    </dependency>
</dependencies>

Con le dipendenze in place, la configurazione YAML definisce le istanze del circuit breaker. Per il portfolio backend usiamo due istanze separate: mlServiceOllama per le chiamate verso le API AI del ml-service (latenza alta, timeout frequenti) e mlServiceHealth per i health check (latenza bassa, failure inattesi). Istanze separate significano state machine separate: un timeout su una query complessa non apre il circuito dei health check.

# application.yml - configurazione Resilience4j per chiamate ml-service LLM
resilience4j:
  circuitbreaker:
    configs:
      # Configurazione base condivisa
      default:
        register-health-indicator: true
        event-consumer-buffer-size: 20

      # Configurazione ottimizzata per LLM con alta latenza
      llmAware:
        sliding-window-type: COUNT_BASED
        sliding-window-size: 20              # Valuta sulle ultime 20 call
        minimum-number-of-calls: 10          # Almeno 10 call prima di decidere
        failure-rate-threshold: 30           # Apri se 30%+ delle call fallisce
        slow-call-rate-threshold: 50         # Apri se 50%+ è più lenta del threshold
        slow-call-duration-threshold: 10s    # "Lenta" = oltre 10 secondi
        wait-duration-in-open-state: 30s     # Rimani OPEN 30s prima di provare HALF_OPEN
        permitted-number-of-calls-in-half-open-state: 3
        automatic-transition-from-open-to-half-open-enabled: true
        record-exceptions:
          - java.io.IOException
          - java.util.concurrent.TimeoutException
          - org.springframework.web.reactive.function.client.WebClientResponseException$ServiceUnavailable
          - org.springframework.web.reactive.function.client.WebClientResponseException$TooManyRequests
        ignore-exceptions:
          # Non apre il CB per errori di validazione input - sono errori del chiamante
          - org.springframework.web.reactive.function.client.WebClientResponseException$BadRequest
          - org.springframework.web.reactive.function.client.WebClientResponseException$UnprocessableEntity

    instances:
      # Chiamate AI verso ml-service (chat, recommend, summarize, explain-code)
      mlServiceOllama:
        base-config: llmAware
        # Soglia più bassa per endpoint AI critici
        failure-rate-threshold: 25
        slow-call-duration-threshold: 15s    # Ollama on-prem può essere lento su query lunghe

      # Health check ml-service - soglie diverse, non mescolare con AI call
      mlServiceHealth:
        base-config: default
        sliding-window-size: 5
        minimum-number-of-calls: 3
        failure-rate-threshold: 60           # Health check fallisce più spesso: soglia più alta
        slow-call-duration-threshold: 2s     # Health check deve essere veloce

# Actuator: esponi endpoint circuitbreakers per Prometheus
management:
  endpoints:
    web:
      exposure:
        include: health,metrics,prometheus,circuitbreakers,circuitbreakerevents
  health:
    circuitbreakers:
      enabled: true
  metrics:
    export:
      prometheus:
        enabled: true

Sezione 2 - Implementazione Java: @CircuitBreaker su WebClient verso ml-service

Con la configurazione YAML in place, il circuit breaker si attiva tramite annotazione @CircuitBreaker sul metodo che chiama il ml-service. Nel portfolio backend, MlServiceClient è il componente che wrappa tutte le chiamate HTTP verso FastAPI. Ogni metodo esposto ha il suo CB nominato e il suo fallback method - un metodo con la stessa firma più un parametro Throwable che riceve l'eccezione e restituisce la risposta degradata.

// MlServiceClient.java - WebClient con @CircuitBreaker per chiamate Ollama
package dev.federicocalo.portfolio.infrastructure.ai;

import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;

import java.time.Duration;
import java.util.Map;
import java.util.logging.Logger;

@Component
public class MlServiceClient {

    private static final Logger log = Logger.getLogger(MlServiceClient.class.getName());

    private final WebClient webClient;

    public MlServiceClient(WebClient.Builder builder) {
        this.webClient = builder
            .baseUrl("http://localhost:8000")  // ml-service FastAPI locale
            .build();
    }

    /**
     * Chiamata al endpoint /internal/ai/chat con circuit breaker.
     * Se il CB è OPEN o la call fallisce, viene invocato chatFallback().
     */
    @CircuitBreaker(name = "mlServiceOllama", fallbackMethod = "chatFallback")
    public Mono<AiChatResponse> chat(AiChatRequest request) {
        return webClient.post()
            .uri("/internal/ai/chat")
            .bodyValue(request)
            .retrieve()
            .bodyToMono(AiChatResponse.class)
            .timeout(Duration.ofSeconds(20))  // Timeout WebClient sotto il slow-call-duration-threshold
            .doOnError(e -> log.warning("ml-service chat error: " + e.getMessage()));
    }

    /**
     * Fallback per chat(): risposta degradata con flag per UX.
     * Stessa firma di chat() + Throwable come ultimo parametro.
     */
    private Mono<AiChatResponse> chatFallback(AiChatRequest request, Throwable ex) {
        log.warning("Circuit breaker attivo per mlServiceOllama. Causa: " + ex.getClass().getSimpleName());
        // Risposta degradata: l'utente vede un messaggio esplicativo invece di un errore
        return Mono.just(AiChatResponse.degraded(
            "Il servizio AI è temporaneamente non disponibile. Riprova tra qualche minuto.",
            ex.getMessage()
        ));
    }

    /**
     * Chiamata a /internal/ai/recommend - endpoint più leggero, stesso CB.
     */
    @CircuitBreaker(name = "mlServiceOllama", fallbackMethod = "recommendFallback")
    public Mono<AiRecommendResponse> recommend(String articleSlug, int limit) {
        return webClient.get()
            .uri(uriBuilder -> uriBuilder
                .path("/internal/ai/recommend")
                .queryParam("slug", articleSlug)
                .queryParam("limit", limit)
                .build())
            .retrieve()
            .bodyToMono(AiRecommendResponse.class)
            .timeout(Duration.ofSeconds(8));
    }

    private Mono<AiRecommendResponse> recommendFallback(String articleSlug, int limit, Throwable ex) {
        // Per i recommend usiamo articoli correlati statici dal database come fallback
        return Mono.just(AiRecommendResponse.staticFallback(articleSlug));
    }

    /**
     * Health check verso ml-service - CB separato, soglie diverse.
     */
    @CircuitBreaker(name = "mlServiceHealth", fallbackMethod = "healthFallback")
    public Mono<Map<String, String>> health() {
        return webClient.get()
            .uri("/health")
            .retrieve()
            .bodyToMono(Map.class)
            .timeout(Duration.ofSeconds(3));
    }

    private Mono<Map<String, String>> healthFallback(Throwable ex) {
        return Mono.just(Map.of("status", "degraded", "reason", ex.getMessage()));
    }
}

Il punto chiave di questo design: il CB mlServiceOllama è condiviso tra chat() e recommend(), ma il CB mlServiceHealth è isolato. Se Ollama è saturo e sia chat che recommend falliscono, il circuito si apre per entrambi - corretto, perché condividono lo stesso provider. Il health check invece rimane operativo e continua a segnalare lo stato reale del ml-service su Prometheus.

Sezione 3 - Half-open probing: canary sintetico invece di traffico reale

La transizione OPEN → HALF_OPEN è il momento più delicato del ciclo di vita di un circuit breaker LLM. Il CB standard di Resilience4j lascia passare permitted-number-of-calls-in-half-open-state richieste reali per testare il recovery. Il problema: quelle 3 richieste potrebbero essere di utenti premium, query da 50k token, o operazioni critiche. Se il provider è ancora instabile, le stai degradando per niente. La soluzione è il probing canary: una richiesta sintetica, leggera, idempotente che verifica la disponibilità senza impatto sull'utente.

# ml_service/app/providers/circuit_breaker.py
# Circuit breaker Python puro con half-open probing canary per Ollama
import asyncio
import time
import logging
from dataclasses import dataclass, field
from enum import Enum
from typing import Callable, Any, Optional

logger = logging.getLogger(__name__)


class CBState(Enum):
    CLOSED = "closed"        # Operativo: tutto il traffico passa
    OPEN = "open"            # Aperto: tutto il traffico bloccato, solo canary
    HALF_OPEN = "half_open"  # Recupero: canary passato, test con traffico limitato


@dataclass
class CircuitBreakerConfig:
    failure_threshold: int = 5       # N errori consecutivi per aprire
    failure_rate: float = 0.30       # 30% failure rate → apri
    slow_call_threshold_s: float = 10.0  # Call oltre 10s = slow call
    slow_call_rate: float = 0.50     # 50% slow calls → apri
    recovery_timeout_s: float = 30.0     # Attesa in OPEN prima di HALF_OPEN
    window_size: int = 20            # Finestra di valutazione (ultime N call)
    half_open_max_calls: int = 3     # Max call in HALF_OPEN prima di decidere


@dataclass
class CallRecord:
    success: bool
    duration_s: float
    timestamp: float = field(default_factory=time.monotonic)


class LLMCircuitBreaker:
    """
    Circuit breaker ottimizzato per chiamate LLM on-prem (Ollama).
    Implementa failure rate + slow call rate con half-open canary probing.
    """

    def __init__(self, name: str, config: CircuitBreakerConfig = None):
        self.name = name
        self.config = config or CircuitBreakerConfig()
        self._state = CBState.CLOSED
        self._call_history: list[CallRecord] = []
        self._opened_at: Optional[float] = None
        self._half_open_calls = 0
        self._lock = asyncio.Lock()

    @property
    def state(self) -> CBState:
        return self._state

    async def call(self, fn: Callable, *args, **kwargs) -> Any:
        """
        Wrappa una chiamata LLM con circuit breaker.
        Lancia CircuitOpenError se il CB è OPEN e il canary non è passato.
        """
        async with self._lock:
            if self._state == CBState.OPEN:
                if not await self._should_attempt_recovery():
                    raise CircuitOpenError(f"Circuit breaker {self.name} è OPEN")
                # Transizione a HALF_OPEN: prima esegui canary
                await self._run_canary()
                self._state = CBState.HALF_OPEN
                self._half_open_calls = 0
                logger.info(f"[CB:{self.name}] Transizione OPEN → HALF_OPEN dopo canary OK")

            if self._state == CBState.HALF_OPEN:
                if self._half_open_calls >= self.config.half_open_max_calls:
                    # Non siamo ancora sicuri: blocca il traffico extra
                    raise CircuitOpenError(f"Circuit breaker {self.name} in HALF_OPEN: limite raggiunto")
                self._half_open_calls += 1

        # Esegui la call reale fuori dal lock per non bloccare concorrenti
        start = time.monotonic()
        try:
            result = await fn(*args, **kwargs)
            duration = time.monotonic() - start
            await self._record_success(duration)
            return result
        except Exception as e:
            duration = time.monotonic() - start
            await self._record_failure(duration)
            raise

    async def _run_canary(self):
        """
        Probe sintetico: chiama Ollama con un prompt minimale ('Rispondi OK').
        Se fallisce, rimane OPEN. Se passa, procede con HALF_OPEN.
        """
        try:
            # Import inline per evitare circular dependency
            from app.providers.ollama import OllamaProvider
            provider = OllamaProvider.get_instance()
            # Prompt minimale, deterministico, bassa latenza attesa
            response = await asyncio.wait_for(
                provider.generate(
                    prompt="Rispondi con una sola parola: OK",
                    model="llama3.1:8b",
                    max_tokens=5
                ),
                timeout=5.0  # Canary deve rispondere in 5s o consideriamo OPEN ancora
            )
            if "ok" not in response.lower():
                raise ValueError(f"Canary response inattesa: {response!r}")
            logger.info(f"[CB:{self.name}] Canary probe OK: '{response.strip()}'")
        except Exception as e:
            logger.warning(f"[CB:{self.name}] Canary probe FALLITO: {e}. CB rimane OPEN")
            raise CircuitOpenError(f"Canary probe fallito per {self.name}: {e}") from e

    async def _record_success(self, duration_s: float):
        async with self._lock:
            record = CallRecord(success=True, duration_s=duration_s)
            self._call_history.append(record)
            self._trim_history()

            if self._state == CBState.HALF_OPEN and self._half_open_calls >= self.config.half_open_max_calls:
                self._state = CBState.CLOSED
                self._opened_at = None
                logger.info(f"[CB:{self.name}] Transizione HALF_OPEN → CLOSED dopo {self._half_open_calls} call ok")

    async def _record_failure(self, duration_s: float):
        async with self._lock:
            record = CallRecord(success=False, duration_s=duration_s)
            self._call_history.append(record)
            self._trim_history()

            if self._state == CBState.HALF_OPEN:
                # Una failure in HALF_OPEN riapre subito
                self._state = CBState.OPEN
                self._opened_at = time.monotonic()
                logger.warning(f"[CB:{self.name}] Failure in HALF_OPEN → OPEN")
                return

            if self._should_open():
                self._state = CBState.OPEN
                self._opened_at = time.monotonic()
                logger.warning(f"[CB:{self.name}] Soglia superata → OPEN")

    def _should_open(self) -> bool:
        """Valuta se failure rate o slow call rate superano la soglia."""
        if len(self._call_history) < self.config.window_size // 2:
            return False  # Dati insufficienti

        window = self._call_history[-self.config.window_size:]
        total = len(window)
        failures = sum(1 for r in window if not r.success)
        slow_calls = sum(1 for r in window if r.duration_s > self.config.slow_call_threshold_s)

        failure_rate = failures / total
        slow_rate = slow_calls / total

        if failure_rate >= self.config.failure_rate:
            logger.warning(f"[CB:{self.name}] Failure rate {failure_rate:.1%} >= {self.config.failure_rate:.1%}")
            return True
        if slow_rate >= self.config.slow_call_rate:
            logger.warning(f"[CB:{self.name}] Slow call rate {slow_rate:.1%} >= {self.config.slow_call_rate:.1%}")
            return True
        return False

    async def _should_attempt_recovery(self) -> bool:
        elapsed = time.monotonic() - (self._opened_at or 0)
        return elapsed >= self.config.recovery_timeout_s

    def _trim_history(self):
        if len(self._call_history) > self.config.window_size * 2:
            self._call_history = self._call_history[-self.config.window_size:]


class CircuitOpenError(Exception):
    """Lanciato quando il circuit breaker è OPEN e non permette il passaggio."""
    pass

Sezione 4 - Smart recovery: exponential backoff con jitter

Il circuit breaker gestisce l'apertura e la chiusura del circuito, ma non la strategia di retry nelle situazioni in cui ha senso riprovare. Un LLM in CLOSED con errori sporadici non deve aprire il circuito se gli errori sono transienti e un retry immediato li risolve. La combinazione ottimale è: retry con exponential backoff + jitter prima di contare il failure verso il circuit breaker. Resilience4j gestisce questo con la combinazione @Retry + @CircuitBreaker - il retry avviene prima, e solo se tutti i retry falliscono il CB registra la failure.

# ml_service/app/providers/retry_policy.py
# Exponential backoff con jitter per chiamate Ollama transitoriamente fallimentari
import asyncio
import random
import logging
from typing import Callable, Any, Type

logger = logging.getLogger(__name__)


async def with_exponential_backoff(
    fn: Callable,
    *args,
    max_attempts: int = 3,
    base_delay_s: float = 1.0,
    max_delay_s: float = 30.0,
    jitter: bool = True,
    retryable_exceptions: tuple[Type[Exception], ...] = (IOError, TimeoutError),
    **kwargs
) -> Any:
    """
    Esegue fn con retry exponential backoff + jitter.
    Lancia l'ultima eccezione se tutti i tentativi falliscono.

    Delay formula: min(base * 2^attempt + jitter, max_delay)
    Jitter range: +/- 20% del delay calcolato
    """
    last_exception = None

    for attempt in range(max_attempts):
        try:
            return await fn(*args, **kwargs)

        except retryable_exceptions as e:
            last_exception = e
            if attempt == max_attempts - 1:
                # Ultimo tentativo fallito - propaga al circuit breaker
                logger.error(f"Tutti {max_attempts} tentativi falliti. Ultimo errore: {e}")
                raise

            # Calcola delay con exponential backoff
            delay = min(base_delay_s * (2 ** attempt), max_delay_s)
            if jitter:
                # Jitter ±20% per evitare thundering herd (tutti i client retryin insieme)
                jitter_amount = delay * 0.2
                delay = delay + random.uniform(-jitter_amount, jitter_amount)

            logger.warning(
                f"Tentativo {attempt + 1}/{max_attempts} fallito ({e}). "
                f"Retry tra {delay:.1f}s"
            )
            await asyncio.sleep(delay)

        except Exception as e:
            # Eccezione non retryable: propaga immediatamente senza retry
            logger.error(f"Errore non retryable: {type(e).__name__}: {e}")
            raise

    raise last_exception  # Safety net - non dovrebbe mai arrivare qui

Il jitter è fondamentale in un contesto LLM con molti utenti concorrenti. Senza jitter, se 50 richieste falliscono insieme, tutte retryin dopo esattamente 2 secondi - inondando di nuovo il provider nel momento esatto in cui potrebbe essersi ripreso parzialmente. Con il jitter ±20%, i 50 retry si distribuiscono su una finestra di 400ms, riducendo il picco di carico del 60-70%.

Sezione 5 - Metriche Prometheus per stato circuit breaker

Senza osservabilità, il circuit breaker è una black box: sai che apre e chiude, ma non quando, per quanto tempo, e con quale frequenza. Resilience4j esporta automaticamente via Micrometer le metriche necessarie. La metrica più importante per il monitoring in produzione è resilience4j_circuitbreaker_state: un gauge che vale 0 (CLOSED), 1 (OPEN), o 2 (HALF_OPEN). Qualsiasi valore diverso da 0 per più di 60 secondi è un segnale di problema che richiede attenzione.

# prometheus/rules/llm_circuit_breaker.yml
# Regole di alerting per circuit breaker LLM - AlertManager
groups:
  - name: llm_circuit_breaker
    interval: 15s
    rules:

      # Alert quando il CB è OPEN per più di 60 secondi
      - alert: LLMCircuitBreakerOpen
        expr: resilience4j_circuitbreaker_state{name="mlServiceOllama"} == 1
        for: 60s
        labels:
          severity: critical
          team: ai-platform
        annotations:
          summary: "Circuit breaker mlServiceOllama è OPEN"
          description: |
            Il circuit breaker per le chiamate AI è aperto da più di 60 secondi.
            Failure rate corrente: {{ $value }}
            Azione: verifica stato Ollama su VPS (systemctl status ollama)

      # Alert quando il CB è in HALF_OPEN - indica recovery in corso
      - alert: LLMCircuitBreakerHalfOpen
        expr: resilience4j_circuitbreaker_state{name="mlServiceOllama"} == 2
        for: 120s
        labels:
          severity: warning
          team: ai-platform
        annotations:
          summary: "Circuit breaker mlServiceOllama bloccato in HALF_OPEN"
          description: |
            Il CB è in HALF_OPEN da più di 2 minuti. Il recovery non completa.
            Possibile causa: Ollama risponde al canary ma fallisce sul traffico reale.

      # Alert su failure rate elevato anche con CB CLOSED
      - alert: LLMHighFailureRate
        expr: |
          resilience4j_circuitbreaker_failure_rate{name="mlServiceOllama"} > 20
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Failure rate LLM > 20% (CB ancora CLOSED)"
          description: |
            Failure rate: {{ printf "%.1f" $value }}%
            Il CB non si è ancora aperto ma la degradazione è in atto.

      # Alert su slow call rate - segnale di saturazione Ollama
      - alert: LLMHighSlowCallRate
        expr: |
          resilience4j_circuitbreaker_slow_call_rate{name="mlServiceOllama"} > 40
        for: 3m
        labels:
          severity: warning
        annotations:
          summary: "Slow call rate LLM > 40% - Ollama sotto pressione"
          description: |
            Il {{ printf "%.1f" $value }}% delle call supera la soglia di 10s.
            Azione: verifica load medio Ollama, considera riduzione concurrency.

Per il Python ml-service, le stesse metriche si espongono via prometheus_client aggiornando manualmente i gauge nello stato interno del circuit breaker. La chiave è aggiornare il gauge ogni volta che lo stato del CB cambia, non a polling - così Prometheus raccoglie sempre il valore corrente al momento dello scrape.

# ml_service/app/providers/circuit_breaker_metrics.py
# Esportazione metriche Prometheus per il circuit breaker Python
from prometheus_client import Gauge, Counter, Histogram
from .circuit_breaker import CBState

# Gauge di stato: 0=CLOSED, 1=OPEN, 2=HALF_OPEN (allineato a Resilience4j)
cb_state_gauge = Gauge(
    "llm_circuit_breaker_state",
    "Stato del circuit breaker LLM (0=CLOSED, 1=OPEN, 2=HALF_OPEN)",
    labelnames=["name"]
)

# Contatori per le transizioni di stato
cb_transitions = Counter(
    "llm_circuit_breaker_transitions_total",
    "Numero di transizioni di stato del circuit breaker",
    labelnames=["name", "from_state", "to_state"]
)

# Histogram per la durata delle call (permette p50, p95, p99)
cb_call_duration = Histogram(
    "llm_circuit_breaker_call_duration_seconds",
    "Durata delle call LLM attraverso il circuit breaker",
    labelnames=["name", "outcome"],  # outcome: success | failure | slow
    buckets=[1, 2, 5, 10, 15, 20, 30, 60]
)

# Contatore per le call bloccate dal CB OPEN
cb_rejected_calls = Counter(
    "llm_circuit_breaker_rejected_calls_total",
    "Call rifiutate dal circuit breaker quando OPEN",
    labelnames=["name"]
)


def update_state_metric(cb_name: str, new_state: CBState):
    """Chiama ogni volta che lo stato del CB cambia."""
    state_value = {
        CBState.CLOSED: 0,
        CBState.OPEN: 1,
        CBState.HALF_OPEN: 2
    }[new_state]
    cb_state_gauge.labels(name=cb_name).set(state_value)

Sezione 6 - Tabelle di riferimento: stati e tuning threshold

Prima di arrivare al tuning empirico, è utile avere una reference chiara degli stati del circuit breaker e del comportamento atteso in ogni scenario LLM. La tabella seguente descrive i tre stati con il flusso del traffico e le azioni consigliate.

Stato Traffico Trigger di entrata Trigger di uscita Azione UX
CLOSED 100% passa Stato iniziale; o recupero da HALF_OPEN Failure rate > 30% o slow call rate > 50% Nessuna - funzionamento normale
OPEN 0% traffico reale; solo canary sintetico ogni recovery_timeout Soglia failure/slow superata Canary probe OK + wait_duration trascorso Mostra messaggio degraded; usa cache o risposta statica
HALF_OPEN Limitato: max permitted_calls richieste reali Canary OK dopo wait_duration in OPEN N call ok → CLOSED; 1 failure → OPEN Possibile lentezza; mantieni disclaimer visibile

Il tuning dei threshold è la parte più critica - e spesso trascurata. I default Resilience4j (50% failure rate, finestra 100 call) sono progettati per servizi HTTP standard con latenza di pochi millisecondi. Per un LLM on-prem con latenza di 2-15 secondi e failure rate baseline del 3-8%, questi default sono troppo alti e troppo lenti. La tabella seguente fornisce valori di partenza empiricamente testati per diversi use case LLM.

Use Case LLM failure-rate-threshold slow-call-duration slow-call-rate-threshold sliding-window-size wait-duration-open
Chat interattiva (bassa latenza) 25% 8s 40% 15 20s
Summarization / RAG (media latenza) 30% 15s 50% 20 30s
Batch processing / analisi lunghe 40% 60s 60% 10 60s
Health check / probe sintetici 60% 2s 70% 5 10s
Ollama on-prem VPS (8GB VRAM) 25% 12s 50% 20 30s

Sezione 7 - FastAPI middleware: integrazione nel ml-service

Nel ml-service FastAPI, il circuit breaker Ollama opera come middleware a livello di provider, non di endpoint HTTP. La distinzione è importante: non vogliamo bloccare tutte le richieste verso il ml-service quando Ollama è down - gli endpoint deterministici (health, tool assist senza AI) devono continuare a funzionare. Solo le richieste che passano per OllamaProvider.generate() passano attraverso il CB.

# ml_service/app/providers/ollama.py - provider con CB integrato
import asyncio
import logging
from app.providers.circuit_breaker import LLMCircuitBreaker, CircuitOpenError, CircuitBreakerConfig

logger = logging.getLogger(__name__)

# Istanza globale del circuit breaker - vive per tutta la durata dell'app
_ollama_cb = LLMCircuitBreaker(
    name="ollama_llama3",
    config=CircuitBreakerConfig(
        failure_threshold=5,
        failure_rate=0.25,          # 25% failure rate
        slow_call_threshold_s=12.0,  # 12s = slow per chat interattiva
        slow_call_rate=0.50,
        recovery_timeout_s=30.0,
        window_size=20,
        half_open_max_calls=3
    )
)


class OllamaProvider:
    """Provider Ollama con circuit breaker e retry integrati."""

    _instance: "OllamaProvider | None" = None

    @classmethod
    def get_instance(cls) -> "OllamaProvider":
        if cls._instance is None:
            cls._instance = cls()
        return cls._instance

    async def generate(self, prompt: str, model: str = "llama3.1:8b", **kwargs) -> str:
        """
        Genera risposta Ollama con circuit breaker.
        Lancia CircuitOpenError se il CB è OPEN.
        Il caller (endpoint FastAPI) gestisce il fallback.
        """
        try:
            return await _ollama_cb.call(
                self._raw_generate,
                prompt=prompt,
                model=model,
                **kwargs
            )
        except CircuitOpenError:
            logger.warning(f"Ollama CB OPEN: richiesta bloccata per '{prompt[:50]}...'")
            raise  # Il caller decide il fallback (cache, statico, errore esplicito)

    async def _raw_generate(self, prompt: str, model: str, max_tokens: int = 1024) -> str:
        """Call raw verso Ollama API - nessuna logica di resilienza qui."""
        import httpx
        async with httpx.AsyncClient(timeout=20.0) as client:
            response = await client.post(
                "http://localhost:11434/api/generate",
                json={
                    "model": model,
                    "prompt": prompt,
                    "stream": False,
                    "options": {"num_predict": max_tokens}
                }
            )
            response.raise_for_status()
            data = response.json()
            return data.get("response", "")

L'endpoint FastAPI che usa il provider gestisce CircuitOpenError con la strategia di graceful degradation descritta in EP03: risposta dalla cache Redis se disponibile, altrimenti messaggio esplicativo con flag ai_degraded: true nel response body per permettere all'Angular frontend di mostrare il disclaimer corretto all'utente.

FAQ

Perché un circuit breaker generico non basta per un LLM in produzione?

Un CB generico tratta tutti gli errori allo stesso modo e apre su una soglia globale. Per un LLM devi distinguere per provider specifico, per modello, e per tipo di query. Un CB multi-dimensionale evita falsi positivi costosi: aprire il circuito quando il provider è sano causa downtime inutile, mentre lasciarlo chiuso durante una degradazione silenziosa causa UX degradata senza visibilità.

Cos'è il half-open probing canary e perché è superiore allo standard?

Nello stato HALF_OPEN standard, il CB lascia passare N richieste reali per testare il recovery. Con un LLM quelle N richieste possono essere query costose di utenti premium. Il canary usa invece richieste sintetiche leggere - un prompt breve come "Rispondi con OK" - per verificare la disponibilità senza consumare risorse reali o degradare l'esperienza utente durante il recovery.

Qual è la differenza tra failure rate threshold e slow call rate threshold?

Il failure rate threshold apre il circuito quando una percentuale di call restituisce errore. Lo slow call rate threshold apre il circuito quando una percentuale di call supera la slow call duration threshold - anche senza errori espliciti. Per i LLM questa distinzione è cruciale: un modello sotto carico può rispondere correttamente ma in 15-30 secondi, degradando l'UX senza mai lanciare un'eccezione.

Come scegliere il failure rate threshold giusto per Ollama on-prem?

Misura il failure rate baseline su 7 giorni: se è sotto il 5%, imposta il threshold al 25-30%. Non usare mai il default del 50% senza misurare prima: rischi di aprire troppo tardi o mai. Per Ollama su VPS con 8GB VRAM e modello llama3.1:8b, i valori testati nel portfolio sono: failure-rate 25%, slow-call-duration 12s, slow-call-rate 50%.

Cosa espone Resilience4j su Prometheus per monitorare il circuit breaker?

Le metriche principali sono: resilience4j_circuitbreaker_state (0=CLOSED, 1=OPEN, 2=HALF_OPEN), resilience4j_circuitbreaker_failure_rate, resilience4j_circuitbreaker_slow_call_rate, e i contatori per call totali, successi, fallimenti e call non permesse. Il gauge di stato è il più utile per alerting: qualsiasi valore != 0 per più di 60 secondi deve triggerare una notifica.

Posso usare il circuit breaker direttamente in FastAPI senza Spring Boot?

Sì. Per il ml-service puoi usare pybreaker oppure un'implementazione Python pura come quella mostrata in questo articolo. L'approccio puro con dataclass e asyncio è preferibile per il controllo totale: nessuna dipendenza aggiuntiva, state machine esplicita, integrazione diretta con prometheus_client. Il middleware FastAPI wrappa ogni call al provider LLM e aggiorna lo stato del CB.

Come coordinare il circuit breaker Spring Boot con quello nel ml-service FastAPI?

L'architettura del portfolio usa due livelli indipendenti: Resilience4j in Spring Boot gestisce le chiamate HTTP verso il ml-service, mentre il CB Python nel ml-service gestisce le chiamate verso Ollama. I due livelli sono separati: puoi avere il CB verso ml-service CLOSED ma il CB verso Ollama OPEN - in quel caso ml-service restituisce una risposta degradata con flag, Spring Boot la riceve e la propaga all'utente con il disclaimer appropriato.

Conclusioni

Il circuit breaker LLM non è un'opzione - è un requisito di produzione. Un LLM on-prem come Ollama ha failure mode che un sistema senza CB non può assorbire: saturazione del thread pool Spring Boot, cascade failure verso tutti gli utenti, impossibilità di recovery automatico senza restart manuale. La configurazione mostrata in questo episodio - Resilience4j con soglie tuned empiricamente, CB multi-dimensionale per istanza, half-open canary probing, Prometheus metrics e AlertManager - riduce il tempo di recovery da minuti a secondi, senza impatto sugli utenti durante il ciclo OPEN → HALF_OPEN → CLOSED.

I takeaway pratici: misura prima il failure rate baseline su 7 giorni, non usare i default Resilience4j per LLM, separa sempre il CB delle call AI da quello dei health check, implementa il canary probing invece del half-open standard per proteggere gli utenti reali durante il recovery. Per il ml-service Python, l'implementazione pura con asyncio.Lock è più controllabile di librerie esterne e si integra nativamente con prometheus_client.

Prossimo episodio: Cached response strategy: quando mostrare cache vs loading - costruiamo la strategia completa di caching per risposte AI, con Redis TTL, invalidation pattern e il decision tree per decidere quando mostrare la cache all'utente e quando preferire il loading indicator.

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