feat(catalog): implement custom circuit breaker for external APIs

Replace Spring Retry CircuitBreakerRetryPolicy with lightweight custom
implementation using ConcurrentHashMap and AtomicReference<Instant>.
Avoids classpath conflict between spring-retry and spring-aspects
RetryTemplate, and eliminates stacked retries with Feign @Retryable.

Behavior: circuit opens for 30s after first failure, returns
fallback immediately without hitting external APIs. Transitions
through CLOSED -> OPEN -> HALF_OPEN -> CLOSED automatically.

Applied to AniListService and MyAnimeListService for all
searchMangaByTitle, getMangasByTitle, and getMangaDataById calls.
This commit is contained in:
Rodrigo Verdiani 2026-07-14 14:12:01 -03:00
parent 331df77d28
commit d398ac8f06
3 changed files with 150 additions and 56 deletions

View File

@ -8,6 +8,7 @@ import com.magamochi.catalog.client.AniListClient;
import com.magamochi.catalog.model.dto.MangaDataDTO;
import com.magamochi.catalog.model.enumeration.MangaStatus;
import com.magamochi.catalog.util.DoubleUtil;
import com.magamochi.common.config.ExternalApiCircuitBreaker;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
@ -26,50 +27,77 @@ import org.springframework.stereotype.Service;
public class AniListService {
private final AniListClient aniListClient;
private final RateLimiter aniListRateLimiter;
private final ExternalApiCircuitBreaker circuitBreaker;
public Map<String, AniListClient.Manga> searchMangaByTitle(String title) {
var request = getSearchGraphQLRequest(title);
return circuitBreaker.execute(
"aniList",
() -> {
var request = getSearchGraphQLRequest(title);
aniListRateLimiter.acquire();
var response = aniListClient.searchManga(request);
aniListRateLimiter.acquire();
var response = aniListClient.searchManga(request);
if (nonNull(response) && nonNull(response.data()) && nonNull(response.data().page())) {
return response.data().page().media().stream()
.flatMap(
manga ->
Stream.of(
manga.title().romaji(),
manga.title().english(),
manga.title().nativeTitle())
.filter(Objects::nonNull)
.filter(t -> !t.isBlank())
.map(titleString -> Map.entry(titleString, manga)))
.collect(
Collectors.toMap(
Map.Entry::getKey, Map.Entry::getValue, (existingManga, manga) -> existingManga));
}
if (nonNull(response) && nonNull(response.data()) && nonNull(response.data().page())) {
return response.data().page().media().stream()
.flatMap(
manga ->
Stream.of(
manga.title().romaji(),
manga.title().english(),
manga.title().nativeTitle())
.filter(Objects::nonNull)
.filter(t -> !t.isBlank())
.map(titleString -> Map.entry(titleString, manga)))
.collect(
Collectors.toMap(
Map.Entry::getKey,
Map.Entry::getValue,
(existingManga, manga) -> existingManga));
}
return Map.of();
return Map.of();
},
throwable -> {
log.error("AniList circuit breaker open for search '{}'", title);
return Map.of();
});
}
public Map<Long, MangaDataDTO> getMangasByTitle(String title) {
var request = getSearchGraphQLRequest(title);
return circuitBreaker.execute(
"aniList",
() -> {
var request = getSearchGraphQLRequest(title);
aniListRateLimiter.acquire();
var response = aniListClient.searchManga(request);
aniListRateLimiter.acquire();
var response = aniListClient.searchManga(request);
return response.data().page().media().stream()
.map(this::parseMangaData)
.flatMap(mangaDataMap -> mangaDataMap.entrySet().stream())
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
return response.data().page().media().stream()
.map(this::parseMangaData)
.flatMap(mangaDataMap -> mangaDataMap.entrySet().stream())
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
},
throwable -> {
log.error("AniList circuit breaker open for search '{}'", title);
return Map.of();
});
}
public MangaDataDTO getMangaDataById(Long aniListId) {
var request = getGraphQLRequest(aniListId);
return circuitBreaker.execute(
"aniList",
() -> {
var request = getGraphQLRequest(aniListId);
aniListRateLimiter.acquire();
var manga = aniListClient.getManga(request).data().Media();
return parseMangaData(manga).get(aniListId);
aniListRateLimiter.acquire();
var manga = aniListClient.getManga(request).data().Media();
return parseMangaData(manga).get(aniListId);
},
throwable -> {
log.error("AniList circuit breaker open for manga ID {}", aniListId);
throw new RuntimeException("AniList API unavailable");
});
}
private Map<Long, MangaDataDTO> parseMangaData(AniListClient.Manga manga) {

View File

@ -8,6 +8,7 @@ import com.magamochi.catalog.client.JikanClient;
import com.magamochi.catalog.model.dto.MangaDataDTO;
import com.magamochi.catalog.model.enumeration.MangaStatus;
import com.magamochi.catalog.util.DoubleUtil;
import com.magamochi.common.config.ExternalApiCircuitBreaker;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
@ -20,45 +21,70 @@ import org.springframework.stereotype.Service;
public class MyAnimeListService {
private final JikanClient jikanClient;
private final RateLimiter jikanRateLimiter;
private final ExternalApiCircuitBreaker circuitBreaker;
/// Searches for manga titles on MyAnimeList using the Jikan API and returns a map of title to MAL
// ID.
public Map<String, Long> searchMangaByTitle(String titleToSearch) {
jikanRateLimiter.acquire();
var results = jikanClient.mangaSearch(titleToSearch).data();
if (results.isEmpty()) {
log.warn("No manga found with title {}", titleToSearch);
return Map.of();
}
return circuitBreaker.execute(
"myAnimeList",
() -> {
jikanRateLimiter.acquire();
var results = jikanClient.mangaSearch(titleToSearch).data();
if (results.isEmpty()) {
log.warn("No manga found with title {}", titleToSearch);
return Map.of();
}
return results.stream()
.collect(
Collectors.toMap(
JikanClient.MangaData::title,
JikanClient.MangaData::mal_id,
(existing, second) -> existing));
return results.stream()
.collect(
Collectors.toMap(
JikanClient.MangaData::title,
JikanClient.MangaData::mal_id,
(existing, second) -> existing));
},
throwable -> {
log.error("MyAnimeList circuit breaker open for search '{}'", titleToSearch);
return Map.of();
});
}
public Map<Long, MangaDataDTO> getMangasByTitle(String title) {
jikanRateLimiter.acquire();
var response = jikanClient.mangaSearch(title).data();
return circuitBreaker.execute(
"myAnimeList",
() -> {
jikanRateLimiter.acquire();
var response = jikanClient.mangaSearch(title).data();
return response.stream()
.map(this::parseMangaData)
.flatMap(mangaDataMap -> mangaDataMap.entrySet().stream())
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
return response.stream()
.map(this::parseMangaData)
.flatMap(mangaDataMap -> mangaDataMap.entrySet().stream())
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
},
throwable -> {
log.error("MyAnimeList circuit breaker open for search '{}'", title);
return Map.of();
});
}
public MangaDataDTO getMangaDataById(Long malId) {
jikanRateLimiter.acquire();
var response = jikanClient.getMangaById(malId);
if (isNull(response) || isNull(response.data())) {
log.warn("No manga found with MAL ID {}", malId);
return null;
}
return circuitBreaker.execute(
"myAnimeList",
() -> {
jikanRateLimiter.acquire();
var response = jikanClient.getMangaById(malId);
if (isNull(response) || isNull(response.data())) {
log.warn("No manga found with MAL ID {}", malId);
return null;
}
var responseData = response.data();
return parseMangaData(responseData).get(malId);
var responseData = response.data();
return parseMangaData(responseData).get(malId);
},
throwable -> {
log.error("MyAnimeList circuit breaker open for manga ID {}", malId);
throw new RuntimeException("MyAnimeList API unavailable");
});
}
private Map<Long, MangaDataDTO> parseMangaData(JikanClient.MangaData data) {

View File

@ -0,0 +1,40 @@
package com.magamochi.common.config;
import java.time.Instant;
import java.util.concurrent.Callable;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Function;
import lombok.extern.log4j.Log4j2;
import org.springframework.stereotype.Component;
@Log4j2
@Component
public class ExternalApiCircuitBreaker {
private static final long OPEN_DURATION_SECONDS = 30;
private final ConcurrentHashMap<String, AtomicReference<Instant>> circuits =
new ConcurrentHashMap<>();
public <T> T execute(String name, Callable<T> callable, Function<Throwable, T> fallback) {
var circuit = circuits.computeIfAbsent(name, k -> new AtomicReference<>(Instant.MIN));
var now = Instant.now();
var openUntil = circuit.get();
if (now.isBefore(openUntil)) {
log.warn("Circuit '{}' OPEN — failing fast", name);
return fallback.apply(null);
}
try {
T result = callable.call();
circuit.set(Instant.MIN);
return result;
} catch (Exception e) {
circuit.set(now.plusSeconds(OPEN_DURATION_SECONDS));
log.error("Circuit '{}' OPEN for {}s: {}", name, OPEN_DURATION_SECONDS, e.getMessage());
return fallback.apply(e);
}
}
}