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.

Commenti
Caricamento commenti...
Accedi per commentare