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:
parent
331df77d28
commit
d398ac8f06
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user