diff --git a/src/main/java/com/magamochi/catalog/service/AniListService.java b/src/main/java/com/magamochi/catalog/service/AniListService.java index 44e3c3a..e35e6d3 100644 --- a/src/main/java/com/magamochi/catalog/service/AniListService.java +++ b/src/main/java/com/magamochi/catalog/service/AniListService.java @@ -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 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 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 parseMangaData(AniListClient.Manga manga) { diff --git a/src/main/java/com/magamochi/catalog/service/MyAnimeListService.java b/src/main/java/com/magamochi/catalog/service/MyAnimeListService.java index 809193b..3717b8e 100644 --- a/src/main/java/com/magamochi/catalog/service/MyAnimeListService.java +++ b/src/main/java/com/magamochi/catalog/service/MyAnimeListService.java @@ -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 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 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 parseMangaData(JikanClient.MangaData data) { diff --git a/src/main/java/com/magamochi/common/config/ExternalApiCircuitBreaker.java b/src/main/java/com/magamochi/common/config/ExternalApiCircuitBreaker.java new file mode 100644 index 0000000..9716eae --- /dev/null +++ b/src/main/java/com/magamochi/common/config/ExternalApiCircuitBreaker.java @@ -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> circuits = + new ConcurrentHashMap<>(); + + public T execute(String name, Callable callable, Function 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); + } + } +}