DEV Community

Oscar Medina Gomes da Costa
Oscar Medina Gomes da Costa

Posted on

Fallback e Degradação resiliente em APIs com Redis e Circuit Breaker

Fallback e Degradação resiliente em APIs com Redis e Circuit Breaker

Parte 1 da série "Building Resilient Java Applications"

🎯 O Problema

Você já passou por isso?

  • Sua API depende de serviços externos que falham aleatoriamente
  • Quando a API de terceiros cai, seu sistema todo quebra
  • Rate limits são atingidos e usuários veem telas de erro
  • Timeouts deixam requisições travadas por segundos

Resultado: Usuários frustrados, SLA comprometido, On-call alerts às 3h da manhã.

💡 A Solução

Neste artigo, vamos construir um Weather Dashboard que demonstra como implementar:

Degradação Resiliente - Sistema continua funcionando mesmo quando tudo dá errado

Fallback Chain - Múltiplas camadas de redundância

Circuit Breaker - Previne cascata de falhas

Cache Inteligente - Redis como camada de resiliência

O que você vai aprender

  • Como implementar fallback chain completo
  • Configurar Circuit Breaker, Retry e Time Limiter (Resilience4j)
  • Usar Redis não apenas como cache, mas como salvação em cenários de desastre
  • Quando retornar dados stale vs erro gracioso

📚 Conceitos

Degradação Resiliente

É a capacidade do sistema continuar operando em modo reduzido quando componentes falham, ao invés de quebrar completamente.

Exemplo do mundo real: Quando o ar-condicionado de um data center falha, os servidores continuam rodando em temperatura elevada (degradado) ao invés de desligar tudo imediatamente.

Cache Stale

Dados expirados mas ainda válidos. No nosso caso, preferimos retornar temperatura de 10 minutos atrás do que mostrar erro ao usuário.

Circuit Breaker Pattern

Inspirado em disjuntores elétricos. Quando muitas falhas acontecem:

  1. CLOSED → Operação normal
  2. OPEN → Para de tentar (evita sobrecarga)
  3. HALF-OPEN → Testa se voltou ao normal

Fallback Chain

Sequência de alternativas tentadas em ordem:

API Primária → API Secundária → Cache Stale → Erro Gracioso
Enter fullscreen mode Exit fullscreen mode

🏗️ Arquitetura da Solução

Diagrama 1: Componentes do Sistema

System-component-arch

Componentes:

  • WeatherController: REST API endpoint
  • WeatherService: Orquestração + fallback logic
  • CacheService: Operações no Redis (get/save/stale)
  • Circuit Breakers: Protegem APIs primária e secundária
  • Retry Policy: 2 tentativas, 500ms intervalo
  • Time Limiter: Timeout 3 segundos
  • Redis: Cache TTL 5 minutos
  • Linhas tracejadas: Caminhos de fallback

Diagrama 2: Fluxo de Decisão

Decision-arch

5 Cenários:

  • Cache Hit: Dados frescos (< 5min) | ~5ms
  • Primary Success: Cache miss → API primária | ~100ms
  • Secondary Success: Primary falha → fallback | ~150ms
  • ⚠️ Stale Cache: Ambas APIs falham → cache expirado | ~8ms
  • Graceful Error: Tudo falha → erro amigável | ~2ms

🛠️ Hands-on Lab

Setup do Projeto

Tech Stack:

  • Java 21
  • Spring Boot 3.2.1
  • Redis 7
  • Resilience4j 2.2.0
  • Docker & Docker Compose

Estrutura:

my-labs-redis-cache/
├── src/main/java/com/mylabs/resilientcache/
│   ├── controller/      # REST endpoints
│   ├── service/         # Lógica de fallback
│   ├── client/          # APIs externas
│   ├── config/          # Configurações
│   └── model/           # DTOs
├── docker-compose.yml
└── Makefile
Enter fullscreen mode Exit fullscreen mode

Clone e execute:

git clone https://github.com/theoscaargomes/my-labs-redis-cache
cd my-labs-redis-cache
make up
Enter fullscreen mode Exit fullscreen mode

1. Configuração do Resilience4j

application.yml:

resilience4j:
  circuitbreaker:
    configs:
      default:
        slidingWindowSize: 10                    # Janela de 10 chamadas
        permittedNumberOfCallsInHalfOpenState: 3 # 3 chamadas em half-open
        waitDurationInOpenState: 10s             # Aguarda 10s antes de half-open
        failureRateThreshold: 50                 # Abre se 50% falharem
        slowCallDurationThreshold: 2s            # Chamadas > 2s = lentas
    instances:
      primaryWeatherApi:
        baseConfig: default
      secondaryWeatherApi:
        baseConfig: default

  retry:
    configs:
      default:
        maxAttempts: 2                           # Máximo 2 tentativas
        waitDuration: 500ms                      # Aguarda 500ms entre tentativas
    instances:
      primaryWeatherApi:
        baseConfig: default
      secondaryWeatherApi:
        baseConfig: default

  timelimiter:
    configs:
      default:
        timeoutDuration: 3s                      # Timeout de 3 segundos
        cancelRunningFuture: true
    instances:
      primaryWeatherApi:
        baseConfig: default
      secondaryWeatherApi:
        baseConfig: default
Enter fullscreen mode Exit fullscreen mode

O que está acontecendo:

  • Circuit Breaker abre após 50% de falhas em janela de 10 chamadas
  • Retry tenta 2x com 500ms de intervalo
  • Time Limiter aborta chamadas que excedem 3s

2. CacheService - Gestão Inteligente de Cache

@Service
@RequiredArgsConstructor
public class CacheService {

    private final RedisTemplate<String, String> redisTemplate;
    private final ObjectMapper objectMapper;

    private static final Duration DEFAULT_TTL = Duration.ofMinutes(5);

    /**
     * Busca dados frescos (dentro do TTL)
     */
    public Optional<WeatherResponse> get(String city) {
        String key = "weather:" + city.toLowerCase();
        String json = redisTemplate.opsForValue().get(key);

        if (json != null) {
            WeatherResponse data = objectMapper.readValue(json, WeatherResponse.class);
            data.setFromCache(true);
            data.setStaleData(false);
            log.info("[CACHE HIT] Fresh data for city: {}", city);
            return Optional.of(data);
        }

        log.info("[CACHE MISS] No fresh data for city: {}", city);
        return Optional.empty();
    }

    /**
     * Busca dados stale (expirados) - último recurso
     */
    public Optional<WeatherResponse> getStale(String city) {
        String key = "weather:" + city.toLowerCase();
        String json = redisTemplate.opsForValue().get(key);

        if (json != null) {
            WeatherResponse data = objectMapper.readValue(json, WeatherResponse.class);
            data.setFromCache(true);
            data.setStaleData(true);
            log.warn("[CACHE STALE] Returning expired data for city: {}", city);
            return Optional.of(data);
        }

        return Optional.empty();
    }

    public void save(String city, WeatherResponse data) {
        String key = "weather:" + city.toLowerCase();
        String json = objectMapper.writeValueAsString(data);
        redisTemplate.opsForValue().set(key, json, DEFAULT_TTL);
        log.info("[CACHE] Saved data for city: {} (TTL: {})", city, DEFAULT_TTL);
    }
}
Enter fullscreen mode Exit fullscreen mode

Ponto-chave: Separamos get() (dados frescos) de getStale() (dados expirados). Isso permite usar cache stale apenas quando todas as APIs falham.

3. WeatherService - Orquestração da Fallback Chain

@Service
@RequiredArgsConstructor
public class WeatherService {

    private final OpenWeatherClient primaryClient;
    private final WeatherApiClient secondaryClient;
    private final CacheService cacheService;

    public WeatherResponse getWeather(String city) {
        log.info("=== Starting weather request for city: {} ===", city);

        // 1. Tenta cache fresco primeiro
        return cacheService.get(city)
                .map(cachedData -> {
                    log.info("[SUCCESS] ✓ Cache hit (fresh data) - ~5ms");
                    return cachedData;
                })
                .orElseGet(() -> {
                    // 2. Cache miss - tenta APIs com fallback
                    WeatherResponse response = fetchWithFallback(city);

                    // Salva no cache se obteve dados válidos
                    if (response != null && !response.getDescription().startsWith("Error")) {
                        cacheService.save(city, response);
                    }

                    return response;
                });
    }

    private WeatherResponse fetchWithFallback(String city) {
        try {
            // 2. Tenta API primária
            return callPrimaryApi(city);
        } catch (Exception e1) {
            log.warn("[FALLBACK] Primary API failed, trying secondary API...");

            try {
                // 3. Tenta API secundária
                return callSecondaryApi(city);
            } catch (Exception e2) {
                log.warn("[FALLBACK] Secondary API failed, trying stale cache...");

                // 4. Último recurso: cache stale
                return cacheService.getStale(city)
                        .map(staleData -> {
                            log.warn("[DEGRADED] ⚠ Using stale cache data");
                            return staleData;
                        })
                        .orElseGet(() -> {
                            // 5. Erro gracioso
                            log.error("[ERROR] ✗ All sources failed");
                            return WeatherResponse.createError(city, 
                                "All weather services are currently unavailable. Please try again later.");
                        });
            }
        }
    }

    @CircuitBreaker(name = "primaryWeatherApi", fallbackMethod = "primaryApiFallback")
    @Retry(name = "primaryWeatherApi")
    @TimeLimiter(name = "primaryWeatherApi")
    public CompletableFuture<WeatherResponse> callPrimaryApi(String city) {
        return CompletableFuture.supplyAsync(() -> {
            try {
                WeatherResponse response = primaryClient.getWeather(city);
                log.info("[SUCCESS] ✓ Primary API - ~100ms");
                return response;
            } catch (Exception e) {
                throw new RuntimeException("Primary API failed", e);
            }
        });
    }

    @CircuitBreaker(name = "secondaryWeatherApi", fallbackMethod = "secondaryApiFallback")
    @Retry(name = "secondaryWeatherApi")
    @TimeLimiter(name = "secondaryWeatherApi")
    public CompletableFuture<WeatherResponse> callSecondaryApi(String city) {
        return CompletableFuture.supplyAsync(() -> {
            try {
                WeatherResponse response = secondaryClient.getWeather(city);
                log.info("[SUCCESS] ✓ Secondary API (fallback) - ~150ms");
                return response;
            } catch (Exception e) {
                throw new RuntimeException("Secondary API failed", e);
            }
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

Arquitetura da Fallback Chain:

  1. Cache fresco (melhor caso)
  2. API primária com Circuit Breaker + Retry + Timeout
  3. API secundária com Circuit Breaker + Retry + Timeout
  4. Cache stale (degradação aceitável)
  5. Erro gracioso (último recurso, mas não quebra)

4. Controller - Endpoints

@RestController
@RequestMapping("/api/weather")
@RequiredArgsConstructor
public class WeatherController {

    private final WeatherService weatherService;
    private final CircuitBreakerRegistry circuitBreakerRegistry;

    @GetMapping("/{city}")
    public ResponseEntity<WeatherResponse> getWeather(@PathVariable String city) {
        WeatherResponse response = weatherService.getWeather(city);
        return ResponseEntity.ok(response);
    }

    @GetMapping("/health")
    public ResponseEntity<Map<String, Object>> health() {
        Map<String, Object> health = new HashMap<>();

        var primaryCB = circuitBreakerRegistry.circuitBreaker("primaryWeatherApi");
        health.put("primaryApi", Map.of(
            "state", primaryCB.getState().toString(),
            "failureRate", primaryCB.getMetrics().getFailureRate() + "%"
        ));

        var secondaryCB = circuitBreakerRegistry.circuitBreaker("secondaryWeatherApi");
        health.put("secondaryApi", Map.of(
            "state", secondaryCB.getState().toString(),
            "failureRate", secondaryCB.getMetrics().getFailureRate() + "%"
        ));

        return ResponseEntity.ok(health);
    }
}
Enter fullscreen mode Exit fullscreen mode

📊 Demonstração dos Cenários

Cenário 1: Happy Path (Cache Hit)

Request:

# Primeira chamada
curl http://localhost:8080/api/weather/London
Enter fullscreen mode Exit fullscreen mode

Logs:

=== Starting weather request for city: London ===
[CACHE MISS] No fresh data for city: London
[PRIMARY API] Calling OpenWeather for city: London
[SUCCESS] ✓ Primary API - ~100ms
[CACHE] Saved data for city: London (TTL: 5min)
Enter fullscreen mode Exit fullscreen mode

Response:

{
  "city": "London",
  "temperature": 18.5,
  "description": "Partly Cloudy",
  "humidity": 72,
  "source": "OpenWeather",
  "fromCache": false,
  "staleData": false
}
Enter fullscreen mode Exit fullscreen mode

Segunda chamada (cache hit):

[CACHE HIT] Fresh data for city: London
[SUCCESS] ✓ Cache hit (fresh data) - ~5ms
Enter fullscreen mode Exit fullscreen mode

Latência: 100ms → 5ms (95% de redução!)


Cenário 2: Primary Falha → Fallback Secondary

Simula falha da API primária:

# Configura 100% de falha na primary
export PRIMARY_FAIL_RATE=1.0
curl http://localhost:8080/api/weather/Paris
Enter fullscreen mode Exit fullscreen mode

Logs:

[PRIMARY API] Calling OpenWeather for city: Paris
[CIRCUIT BREAKER] Primary API failed: OpenWeather API unavailable
[FALLBACK] Primary API failed, trying secondary API...
[SECONDARY API] Calling WeatherAPI for city: Paris
[SUCCESS] ✓ Secondary API (fallback) - ~150ms
Enter fullscreen mode Exit fullscreen mode

Response:

{
  "city": "Paris",
  "temperature": 16.2,
  "source": "WeatherAPI",
  "fromCache": false
}
Enter fullscreen mode Exit fullscreen mode

Sistema continua funcionando!


Cenário 3: Ambas APIs Falham → Cache Stale

Primeiro, popula cache:

curl http://localhost:8080/api/weather/Berlin
# Aguarda 10 minutos (cache expira)
Enter fullscreen mode Exit fullscreen mode

Depois, simula falha de ambas APIs:

export PRIMARY_FAIL_RATE=1.0
export SECONDARY_FAIL_RATE=1.0
curl http://localhost:8080/api/weather/Berlin
Enter fullscreen mode Exit fullscreen mode

Logs:

[CIRCUIT BREAKER] Primary API circuit breaker activated
[FALLBACK] Primary API failed, trying secondary API...
[CIRCUIT BREAKER] Secondary API circuit breaker activated
[FALLBACK] Secondary API failed, trying stale cache...
[CACHE STALE] Returning expired data for city: Berlin
[DEGRADED] ⚠ Using stale cache data
Enter fullscreen mode Exit fullscreen mode

Response:

{
  "city": "Berlin",
  "temperature": 14.8,
  "source": "OpenWeather",
  "fromCache": true,
  "staleData": true
}
Enter fullscreen mode Exit fullscreen mode

Dados de 10min atrás, mas melhor que erro! ⚠️


Cenário 4: Tudo Falha → Erro Gracioso

Nova cidade sem cache:

curl http://localhost:8080/api/weather/Tokyo
Enter fullscreen mode Exit fullscreen mode

Logs:

[CACHE MISS] No fresh data for city: Tokyo
[CIRCUIT BREAKER] Primary API circuit breaker activated
[FALLBACK] Secondary API failed, trying stale cache...
[CACHE EMPTY] No stale data available for city: Tokyo
[ERROR] ✗ All sources failed, returning graceful error
Enter fullscreen mode Exit fullscreen mode

Response:

{
  "city": "Tokyo",
  "description": "Error: All weather services are currently unavailable. Please try again later.",
  "timestamp": "2026-01-29T02:30:00Z",
  "fromCache": false
}
Enter fullscreen mode Exit fullscreen mode

HTTP 200 com mensagem amigável, não HTTP 500!


Cenário 5: Circuit Breaker em Ação

Depois de várias falhas, o Circuit Breaker abre e para de tentar:

Health check:

curl http://localhost:8080/api/weather/health
Enter fullscreen mode Exit fullscreen mode

Response:

{
  "primaryApi": {
    "state": "OPEN",
    "failureRate": "100.0%"
  },
  "secondaryApi": {
    "state": "CLOSED",
    "failureRate": "0.0%"
  }
}
Enter fullscreen mode Exit fullscreen mode

Circuit aberto = pula direto para fallback (evita sobrecarga)

📈 Resultados

Latência por Cenário

Cenário Latência Status
Cache Hit ~5ms ✅ Ideal
Primary API ~100ms ✅ Normal
Secondary API ~150ms ✅ Degraded
Cache Stale ~8ms ⚠️ Degraded
Graceful Error ~2ms ❌ Error

Disponibilidade

Sem resiliência:

Primary API availability: 70%
System availability: 70%
Enter fullscreen mode Exit fullscreen mode

Com fallback chain:

Primary API: 70%
Secondary API: 80%
Cache: 99.9%
System availability: 99.9%+
Enter fullscreen mode Exit fullscreen mode

Melhoria: De 70% para 99.9% de disponibilidade!

🎯 Trade-offs

Vantagens

Alta disponibilidade - Sistema raramente fica indisponível

Performance - Cache reduz latência drasticamente

Custo - Menos chamadas para APIs pagas

UX - Usuário sempre recebe alguma resposta

Desvantagens

⚠️ Complexidade - Mais código para manter

⚠️ Stale data - Dados podem estar desatualizados

⚠️ Debug - Mais difícil rastrear problemas

⚠️ Infra - Dependência adicional (Redis)

Quando Usar

✅ Dados que mudam com frequência moderada (clima, cotações)

✅ APIs externas instáveis ou com rate limit

✅ Alta disponibilidade é crítica

✅ Tolerância a dados levemente desatualizados

Quando NÃO Usar

❌ Dados críticos (transações financeiras)

❌ APIs muito estáveis e rápidas

❌ Dados que mudam em tempo real

❌ Sistemas simples sem necessidade de HA

🎓 Conclusão

Neste artigo, construímos um sistema resiliente que:

  1. Nunca quebra - Sempre retorna algo útil ao usuário
  2. Degrada mas com resiliencia - Prioriza dados frescos, mas aceita stale quando necessário
  3. Protege APIs externas - Circuit Breaker previne sobrecarga
  4. Melhora performance - Cache reduz latência em 95%

Principais aprendizados:

  • Cache não é apenas performance, é resiliência
  • Dados stale > erro para usuário
  • Circuit Breaker economiza recursos e acelera fallback
  • Sempre tenha um plano B, C e D

Próximo Artigo

Integration Testing with Testcontainers - Como testar toda essa resiliência de forma automatizada, com Redis e APIs mockadas rodando em containers.

💻 Source Code

Código completo disponível no GitHub:

📦 github.com/theoscaargomes/my-labs-redis-cache

Para rodar localmente:

git clone https://github.com/theoscaargomes/my-labs-redis-cache
cd my-labs-redis-cache
make up
make test-happy
Enter fullscreen mode Exit fullscreen mode

Gostou? Deixe um ⭐ no repo e compartilhe com sua equipe!

Dúvidas? Comenta aqui embaixo que eu respondo.


Autor: Oscar Gomes

Série: Building Resilient Java Applications

Artigo: #1 - Redis as Resilience Layer

Top comments (0)