Resiliência

Fase 3 concluída — CircuitBreaker + Retry implementados em v3.1.0 e v3.2.0.

O problema

O SpeechToTextClient faz uma chamada bloqueante ao Whisper com .block(Duration.ofSeconds(30)). Se o Speaches estiver lento, sobrecarregado ou offline:

  Requisição do cliente
         │
         ▼
  SpeechToTextClient.transcribe()
         │
         │  .block(30s)  ←── thread bloqueada por até 30 segundos
         ▼
  Speaches (pode estar lento / offline)
         │
         │  timeout ou erro
         ▼
  TranscriptionException("Falha na comunicação com Whisper")
         │
         ▼
  HTTP 400  ←── sem retry, sem fallback, sem circuit breaker

Sob carga, threads acumulam esperando o timeout → pool de threads esgotado → cascata de falhas.

Cadeia de resiliência implementada

Padrões aplicados em camadas sobre o SpeechToTextClient, na ordem de execução:

  Requisição → TranscriptionService → SpeechToTextClient
                                             │
                        ┌────────────────────┘
                        │
                        ▼
  ┌──────────────────────────────────────────────────────────────────┐
  │  @Retry(name="whisper")  ←── v3.2.0                              │
  │  maxAttempts=3 · backoff 500ms → 1s → 2s (exponencial, max 4s)   │
  │  retry apenas em TranscriptionException (erros de I/O)           │
  │  ignora ServiceUnavailableException + IllegalArgumentException   │
  └───────────────────────────────┬──────────────────────────────────┘
                                  │ (se ainda falhar após 3 tentativas)
                                  ▼
  ┌──────────────────────────────────────────────────────────────────┐
  │  @CircuitBreaker(name="whisper")  ←── v3.1.0                     │
  │                                                                  │
  │  CLOSED ──(≥50% falhas em 10 calls)──► OPEN                      │
  │    ↑                                      │                      │
  │    └──(3 calls ok em HALF_OPEN)──◄── (30s)                       │
  │                                      HALF_OPEN                   │
  └───────────────────────────────┬──────────────────────────────────┘
                                  │ (se OPEN → fallback imediato)
                                  ▼
  ┌──────────────────────────────────────────────────────────────────┐
  │  Fallback                                                        │
  │  ServiceUnavailableException                                     │
  │  → GlobalExceptionHandler → HTTP 503 ProblemDetail               │
  └──────────────────────────────────────────────────────────────────┘

Estados do Circuit Breaker

EstadoComportamentoTransição
CLOSED Operação normal — chamadas passam para o Speaches → OPEN se ≥ 50% das últimas 10 chamadas falharem (mín. 5)
OPEN Circuito aberto — fallback imediato, sem chamar Speaches → HALF_OPEN após 30s (prod) / 10s (dev)
HALF_OPEN Sondagem — 3 chamadas de teste permitidas → CLOSED se as 3 passarem; → OPEN se qualquer uma falhar

Configuração implementada

application.yml

resilience4j:
  circuitbreaker:
    instances:
      whisper:
        slidingWindowSize: 10             # prod | 5 em dev
        failureRateThreshold: 50          # % de falhas para abrir
        waitDurationInOpenState: 30s      # prod | 10s em dev
        permittedNumberOfCallsInHalfOpenState: 3
        minimumNumberOfCalls: 5           # prod | 3 em dev
        registerHealthIndicator: true     # visível em /actuator/health
        slowCallDurationThreshold: 10s    # chamadas lentas = falha
        slowCallRateThreshold: 80

  retry:
    instances:
      whisper:
        maxAttempts: 3                    # prod | 2 em dev
        waitDuration: 500ms
        enableExponentialBackoff: true
        exponentialBackoffMultiplier: 2   # 500ms → 1s → 2s
        exponentialMaxWaitDuration: 4s
        retryExceptions:
          - TranscriptionException
        ignoreExceptions:
          - ServiceUnavailableException
          - IllegalArgumentException

SpeechToTextClient.java

// Ordem importa: Retry executa primeiro, CircuitBreaker por baixo
@Retry(name = "whisper", fallbackMethod = "fallback")
@CircuitBreaker(name = "whisper", fallbackMethod = "fallback")
public WhisperResponse transcribe(MultipartFile file) { ... }

private WhisperResponse fallback(MultipartFile file, Exception ex) {
    throw new ServiceUnavailableException(
        "Serviço de transcrição temporariamente indisponível.");
}

Métricas de resiliência no Grafana v3.3.0

Resilience4j integra com Micrometer automaticamente. Dashboard Grafana atualizado em v3.3.0 com a seção Resiliência.

PainelMétrica Prometheus
Estado do CBresilience4j_circuitbreaker_state{name="whisper"}
Chamadas por tiporesilience4j_circuitbreaker_calls_seconds_count{kind=...}
Retriesresilience4j_retry_calls_total{kind=...}

Como testar o comportamento

# 1. Ver estado atual do CB
curl http://localhost:8080/actuator/health | jq .components.circuitBreakers

# 2. Forçar abertura do CB (derrubar Speaches e fazer requisições)
docker stop speaches

TOKEN=$(curl -s -X POST http://localhost:8080/auth/token \
  -H 'Content-Type: application/json' \
  -d '{"username":"user","password":"any"}' | jq -r .token)

# Fazer 5+ requisições — as primeiras retornam 400 (erro Whisper),
# depois do threshold retornam 503 (circuito aberto)
for i in {1..6}; do
  echo -n "Req $i: "
  curl -s -o /dev/null -w "%{http_code}" \
    -X POST http://localhost:8080/api/transcriptions \
    -H "Authorization: Bearer $TOKEN" \
    -F "file=@audio.wav;type=audio/wav"
  echo ""
done

# 3. Restaurar Speaches — CB fecha após waitDurationInOpenState
docker start speaches

Timeline de implementação

v3.1.0 ✓
CircuitBreaker
Resilience4j + AOP. @CircuitBreaker em SpeechToTextClient. Fallback → ServiceUnavailableException → 503. Estado visível em /actuator/health.
v3.2.0 ✓
Retry com backoff exponencial
@Retry wrapping @CircuitBreaker. 3 tentativas, 500ms→1s→2s. Retry apenas em erros de I/O, ignora erros de validação e CB aberto.
v3.3.0 ✓
Métricas no Grafana
Dashboard atualizado com seção Resiliência: estado CB (stat), chamadas por tipo (timeseries), retries por resultado (timeseries).