Le architetture distribuite moderne sono progettate per la resilienza. Aggiungiamo retry per i fallimenti transitori, replica per la durabilità, autoscaling per l’elasticità, circuit breaker per l’isolamento. Ogni meccanismo, preso singolarmente, migliora la disponibilità. Ma sotto stress, la loro interazione può abbattere l’intero sistema.
La maggior parte delle interruzioni enterprise non è causata dall’assenza di fault tolerance. È causata da meccanismi di fault tolerance non delimitati che reagiscono simultaneamente. Questo articolo analizza il fenomeno del retry storm e mostra come progettare sistemi con bounded reliability.
1. Retry Storm: quando la resilienza moltiplica il traffico
I retry sono progettati per proteggere dai fallimenti temporanei. Ma i retry moltiplicano il carico. Ecco un esempio semplificato della logica di retry che si trova comunemente nei sistemi di integrazione tra servizi:
import time
import random
def downstream_service():
latency = random.choice([0.1, 0.2, 0.8])
time.sleep(latency)
if latency > 0.7:
raise TimeoutError("Slow response")
return "OK"
def call_with_retries(max_attempts=3):
for attempt in range(max_attempts):
try:
return downstream_service()
except TimeoutError:
print(f"Retry {attempt+1}")
raise Exception("Failed after retries")
In condizioni normali questa logica funziona correttamente. Ma sotto carico elevato si innesca una spirale:
- La latenza aumenta
- Scattano i timeout
- Ogni richiesta viene ritentata fino a 3 volte
- Il traffico verso il backend triplica
- Il backend rallenta ulteriormente
- Aumentano i retry
In un’architettura a livelli tipica — Gateway → Experience API → Process API → System API → Database — se ogni livello gestisce i retry in modo indipendente, l’amplificazione del carico diventa moltiplicativa, non additiva. Un singolo rallentamento del database può abbattere tre livelli API a cascata in pochi minuti.
Il pattern Bounded Retry (sicuro per la produzione)
La soluzione non è eliminare i retry, ma delimitarli e renderli consapevoli del carico di sistema:
def call_with_bounded_retries(max_attempts=2, system_load=0.5):
# Fail-fast quando il sistema è sotto stress
if system_load > 0.75:
return None
for attempt in range(max_attempts):
try:
return downstream_service()
except TimeoutError:
# Exponential backoff con jitter
backoff = 0.2 * (2 ** attempt)
time.sleep(backoff + random.uniform(0, 0.1))
return None
Le differenze chiave rispetto alla versione base:
- Ceiling sui retry: massimo 2 tentativi invece di 3
- Exponential backoff: aumenta il tempo di attesa ad ogni tentativo
- Jitter: aggiunge variabilità casuale per evitare wave di retry sincronizzate
- Load-aware circuit: disabilita i retry completamente quando il sistema è sovraccarico
2. Fan-out della replica e collasso della coordinazione
La replica sincrona migliora la durabilità dei dati. Ma ogni write si moltiplica per il numero di repliche, aumentando il costo di coordinazione:
def write_to_replicas(data, replicas=3):
for _ in range(replicas):
time.sleep(0.2) # Simula latenza di replica
Sotto traffico elevato, il lag delle repliche cresce, i client iniziano a ritentare le scritture, e il carico effettivo di write raddoppia. In sistemi di elaborazione aziendale (ordini, fatturazione, riconciliazione) questo pattern causa un collasso del throughput non perché i dati vadano persi, ma perché la coordinazione sopraffà il sistema.
La soluzione è la durabilità stratificata: non tutte le scritture richiedono le stesse garanzie. Le transazioni critiche usano replica completa; log ed eventi non critici ne richiedono meno. La reliability deve essere proporzionata, non massimizzata ciecamente.
3. Loop di feedback dell’autoscaling
L’autoscaling reagisce alle metriche di traffico. Ma se queste metriche sono gonfiate dai retry, il sistema escala in risposta a traffico artificiale:
def autoscale_safe(request_rate, sustained_load):
# Scala su domanda sostenuta, non su spike da retry
if sustained_load and request_rate > 120:
print("Scaling up — domanda organica confermata")
Segnali più affidabili su cui basare l’autoscaling:
- Domanda sostenuta (non spike improvvisi)
- Tendenze nella distribuzione della latenza (P95, P99)
- RPS organiche (esclusi i retry)
- Velocità di crescita delle code
4. Il problema reale: le reazioni correlate
Retry, replica e autoscaling reagiscono ciascuno a segnali diversi. Ma sotto stress, reagiscono tutti allo stesso segnale di degradazione. Questa correlazione crea il fallimento a cascata.
Scenario reale — payment reconciliation service:
- La latenza dell’ERP sale a 700ms
- Il servizio Billing va in timeout a 500ms
- Billing ritenta 3 volte
- La Process API ritenta l’orchestrazione
- Il Gateway ritenta la richiesta client
- L’autoscaling reagisce allo spike
- Il lag di replica del database aumenta
- La Dead Letter Queue inizia a riempirsi
In pochi minuti, un rallentamento minore diventa un’interruzione di piattaforma. La causa principale: reazione non delimitata.
5. Pattern di Bounded Reliability per sistemi API
Retry Budget
Il carico effettivo è: Carico Effettivo = RPS in ingresso × Numero Retry. Con 1.000 RPS e 3 retry, il backend riceve 3.000 richieste. Impostare un budget massimo di retry per richiesta e per servizio è non negoziabile in produzione.
Classificazione degli Errori
Non tutti gli errori sono retriable. Una tassonomia chiara:
| Tipo di Errore | Retry? | Azione |
|---|---|---|
| CONNECTIVITY | Sì | Bounded retry |
| TIMEOUT | Sì | Backoff esponenziale |
| VALIDATION (4xx) | No | Fail fast |
| AUTH (401/403) | No | Alert immediato |
I retry ciechi su errori di validazione o autenticazione sono debito architetturale.
Idempotency obbligatoria
I retry senza idempotency causano corruzione dei dati. Il transaction ID deve essere deterministic, non generato casualmente ad ogni tentativo:
# ❌ Non sicuro: genera un nuovo ID ad ogni retry
transaction_id = uuid()
# ✅ Sicuro: riusa l'ID dalla richiesta originale
transaction_id = payload.get("transaction_id") or request.headers["correlation-id"]
Dead Letter Queue con Osservabilità
Tracciare le seguenti metriche come segnali di early warning:
- Percentuale di retry sul totale delle richieste
- Frequenza dei timeout per endpoint
- Velocità di crescita della DLQ
- Shift nella distribuzione P95 della latenza
Conclusione
Le retry storm non iniziano con un fallimento catastrofico. Iniziano con un piccolo aumento di latenza, qualche timeout, una manciata di retry. Poi i meccanismi di fault tolerance reagiscono insieme — e la loro interazione non controllata trasforma un disagio minore in un’interruzione totale.
La resilienza nelle architetture distribuite non significa aggiungere più safety net. Significa controllare come quei safety net si comportano sotto stress. Delimita i retry. Classifica i fallimenti. Forza l’idempotency. Scala su domanda organica. Monitora i loop di feedback prima che si avvitino.
La differenza tra una piattaforma resiliente e un fallimento a cascata sta quasi sempre nella risposta a una sola domanda: i tuoi meccanismi di reliability sono controllati o sono illimitati?
Fonte: How Retry Storms Crash API-Led Systems: Bounded Reliability Patterns — DZone