diff --git a/build.gradle b/build.gradle index 5cec3342..eccb4b84 100644 --- a/build.gradle +++ b/build.gradle @@ -25,6 +25,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-aop' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'io.github.resilience4j:resilience4j-spring-boot3:2.4.0' implementation 'io.jsonwebtoken:jjwt-api:0.12.6' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' diff --git a/src/main/java/com/example/inflace/domain/brandcollaboration/service/BrandCollaborationService.java b/src/main/java/com/example/inflace/domain/brandcollaboration/service/BrandCollaborationService.java index 491b2cf5..0d7bce55 100644 --- a/src/main/java/com/example/inflace/domain/brandcollaboration/service/BrandCollaborationService.java +++ b/src/main/java/com/example/inflace/domain/brandcollaboration/service/BrandCollaborationService.java @@ -41,11 +41,14 @@ import java.util.Map; import java.util.Objects; import java.util.OptionalDouble; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; import java.util.function.Function; import java.util.stream.Collectors; -import org.springframework.data.domain.Limit; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Limit; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; @@ -66,6 +69,7 @@ public class BrandCollaborationService { private static final int SHORTS_MAX_DURATION_SECONDS = 180; private static final int RECENT_UPLOADS_COUNT = 10; private static final int DEFAULT_VIDEO_COUNT = 9; + private static final Duration RECENT_UPLOAD_DAYS_TIMEOUT = Duration.ofSeconds(6); private static final ZoneId KST = ZoneId.of("Asia/Seoul"); private static final String DEFAULT_VIDEOS_CACHE_KEY = "brand-collaboration:default-videos"; @@ -78,6 +82,7 @@ public class BrandCollaborationService { private final RedisTemplate redisTemplate; private final VideoRepository videoRepository; private final VideoStatsRepository videoStatsRepository; + private final ExecutorService externalApiExecutor; public CursorSliceResponse search(BrandCollaborationSearchCondition condition) { if (condition.includeKeywords().isEmpty()) { @@ -269,24 +274,45 @@ private List computeCategoryDist } private Double computeAvgUploadDays(Map channelMap) { - OptionalDouble avg = channelMap.values().stream() + List> futures = channelMap.values().stream() .filter(c -> c.contentDetails() != null && c.contentDetails().relatedPlaylists() != null) .map(c -> c.contentDetails().relatedPlaylists().uploads()) .filter(StringUtils::hasText) - .mapToDouble(playlistId -> { - List instants = youtubeDataApiClient - .getRecentUploadDates(playlistId, RECENT_UPLOADS_COUNT) - .stream().map(Instant::parse).sorted().toList(); - if (instants.size() < 2) return Double.NaN; - long spanSeconds = instants.getLast().getEpochSecond() - instants.getFirst().getEpochSecond(); - return spanSeconds / 86400.0 / (instants.size() - 1); - }) + .map(playlistId -> CompletableFuture + .supplyAsync(() -> computeUploadDays(playlistId), externalApiExecutor) + .orTimeout( + RECENT_UPLOAD_DAYS_TIMEOUT.toSeconds(), + TimeUnit.SECONDS + ) + .exceptionally(exception -> { + log.warn("Failed to compute average upload days. playlistId={}", playlistId, exception); + return Double.NaN; + })) + .toList(); + + OptionalDouble avg = futures.stream() + .map(CompletableFuture::join) + .mapToDouble(Double::doubleValue) .filter(d -> !Double.isNaN(d) && d > 0) .average(); return avg.isPresent() ? avg.getAsDouble() : null; } + private Double computeUploadDays(String playlistId) { + List instants = youtubeDataApiClient + .getRecentUploadDates(playlistId, RECENT_UPLOADS_COUNT) + .stream() + .map(Instant::parse) + .sorted() + .toList(); + if (instants.size() < 2) { + return Double.NaN; + } + long spanSeconds = instants.getLast().getEpochSecond() - instants.getFirst().getEpochSecond(); + return spanSeconds / 86400.0 / (instants.size() - 1); + } + private void validateKeywords(List includeKeywords, List excludeKeywords) { boolean hasDuplicate = includeKeywords.stream().anyMatch(excludeKeywords::contains); if (hasDuplicate) { diff --git a/src/main/java/com/example/inflace/domain/channel/service/sync/YoutubeChannelAnalyticsFetchService.java b/src/main/java/com/example/inflace/domain/channel/service/sync/YoutubeChannelAnalyticsFetchService.java index 8296e4f7..dae8f886 100644 --- a/src/main/java/com/example/inflace/domain/channel/service/sync/YoutubeChannelAnalyticsFetchService.java +++ b/src/main/java/com/example/inflace/domain/channel/service/sync/YoutubeChannelAnalyticsFetchService.java @@ -20,6 +20,9 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; import java.util.stream.Collectors; import java.util.stream.IntStream; import lombok.RequiredArgsConstructor; @@ -33,6 +36,7 @@ public class YoutubeChannelAnalyticsFetchService { private static final int ANALYTICS_END_DATE_OFFSET_DAYS = 3; private static final int SUBSCRIBER_LOG_MAX_BACKFILL_DAYS = 365; + private static final int VIDEO_ANALYTICS_BATCH_SIZE = 4; private static final List CHANNEL_METRICS = List.of( "views", @@ -57,12 +61,18 @@ public class YoutubeChannelAnalyticsFetchService { ); private final YoutubeAnalyticsApiClient youtubeAnalyticsApiClient; + private final ExecutorService externalApiExecutor; public YoutubeAnalyticsSyncData fetchAnalytics(String googleId, AnalyticsSyncContext context) { LocalDate endDate = resolveEndDate(); + CompletableFuture channelAnalyticsFuture = CompletableFuture + .supplyAsync(() -> fetchChannelAnalytics(googleId, context, endDate), externalApiExecutor); + CompletableFuture> subscriberLogsFuture = CompletableFuture + .supplyAsync(() -> fetchSubscriberLogs(googleId, context, endDate), externalApiExecutor); + return new YoutubeAnalyticsSyncData( - fetchChannelAnalytics(googleId, context, endDate), - fetchSubscriberLogs(googleId, context, endDate), + channelAnalyticsFuture.join(), + subscriberLogsFuture.join(), fetchVideoAnalytics(googleId, context.videos(), endDate), fetchAudienceRetention(googleId, context.videos(), endDate) ); @@ -185,43 +195,63 @@ private List fetchVideoAnalytics( LocalDate endDate ) { List result = new ArrayList<>(); - for (AnalyticsSyncContext.VideoContext video : videos) { - LocalDate startDate = resolveStartDate(video.publishedAt(), endDate); - if (startDate.isAfter(endDate)) { - continue; - } + for (List batch : chunked(videos, VIDEO_ANALYTICS_BATCH_SIZE)) { + List> futures = batch.stream() + .map(video -> CompletableFuture + .supplyAsync(() -> fetchVideoAnalyticsForVideo(googleId, video, endDate), externalApiExecutor) + .exceptionally(exception -> { + log.warn("Failed to schedule video analytics sync. videoId={} youtubeVideoId={}", + video.videoId(), video.youtubeVideoId(), exception); + return defaultVideoAnalyticsData(video.videoId(), !video.existingVideoAnalytics()); + })) + .toList(); + + result.addAll(futures.stream() + .map(CompletableFuture::join) + .filter(Objects::nonNull) + .toList()); + } + return result; + } - YoutubeAnalyticsVideoRequest request = new YoutubeAnalyticsVideoRequest( - startDate, - endDate, - VIDEO_METRICS, - video.youtubeVideoId(), - "video", - null - ); - Map summary; - try { - summary = querySingleRow(googleId, request); - } catch (ApiException e) { - log.warn("Failed to sync video analytics summary. videoId={} youtubeVideoId={} startDate={} endDate={}", - video.videoId(), video.youtubeVideoId(), startDate, endDate, e); - result.add(defaultVideoAnalyticsData(video.videoId(), !video.existingVideoAnalytics())); - continue; - } + private YoutubeAnalyticsSyncData.VideoAnalyticsData fetchVideoAnalyticsForVideo( + String googleId, + AnalyticsSyncContext.VideoContext video, + LocalDate endDate + ) { + LocalDate startDate = resolveStartDate(video.publishedAt(), endDate); + if (startDate.isAfter(endDate)) { + return null; + } - if (summary.isEmpty() && !video.existingVideoAnalytics()) { - continue; - } + YoutubeAnalyticsVideoRequest request = new YoutubeAnalyticsVideoRequest( + startDate, + endDate, + VIDEO_METRICS, + video.youtubeVideoId(), + "video", + null + ); + Map summary; + try { + summary = querySingleRow(googleId, request); + } catch (ApiException e) { + log.warn("Failed to sync video analytics summary. videoId={} youtubeVideoId={} startDate={} endDate={}", + video.videoId(), video.youtubeVideoId(), startDate, endDate, e); + return defaultVideoAnalyticsData(video.videoId(), !video.existingVideoAnalytics()); + } - result.add(fetchUnsubscribedVideoAnalytics( - googleId, - video, - startDate, - endDate, - summary - )); + if (summary.isEmpty() && !video.existingVideoAnalytics()) { + return null; } - return result; + + return fetchUnsubscribedVideoAnalytics( + googleId, + video, + startDate, + endDate, + summary + ); } private YoutubeAnalyticsSyncData.VideoAnalyticsData fetchUnsubscribedVideoAnalytics( @@ -292,54 +322,75 @@ private List fetchAudienceRetent LocalDate endDate ) { List result = new ArrayList<>(); - for (AnalyticsSyncContext.VideoContext video : videos) { - LocalDate startDate = resolveStartDate(video.publishedAt(), endDate); - if (startDate.isAfter(endDate)) { - continue; - } + for (List batch : chunked(videos, VIDEO_ANALYTICS_BATCH_SIZE)) { + List>> futures = batch.stream() + .map(video -> CompletableFuture + .supplyAsync(() -> fetchAudienceRetentionForVideo(googleId, video, endDate), externalApiExecutor) + .exceptionally(exception -> { + log.warn("Failed to schedule audience retention sync. videoId={} youtubeVideoId={}", + video.videoId(), video.youtubeVideoId(), exception); + return List.of(); + })) + .toList(); + + result.addAll(futures.stream() + .map(CompletableFuture::join) + .flatMap(List::stream) + .toList()); + } + return result; + } - YoutubeAnalyticsVideoRequest request = new YoutubeAnalyticsVideoRequest( - startDate, - endDate, - VIDEO_RETENTION_METRICS, - video.youtubeVideoId(), - "elapsedVideoTimeRatio", - null - ); - try { - YoutubeAnalyticsVideoResponse response = youtubeAnalyticsApiClient.getYoutubeAnalytics(googleId, request); - if (response.rows() == null || response.rows().isEmpty()) { - continue; - } + private List fetchAudienceRetentionForVideo( + String googleId, + AnalyticsSyncContext.VideoContext video, + LocalDate endDate + ) { + LocalDate startDate = resolveStartDate(video.publishedAt(), endDate); + if (startDate.isAfter(endDate)) { + return List.of(); + } - Map indexMap = buildIndexMap(response.columnHeaders()); - LocalDateTime now = LocalDateTime.now(); - - List retentions = response.rows().stream() - .map(row -> new YoutubeAnalyticsSyncData.AudienceRetentionPointData( - toDouble(row.get(indexMap.get("elapsedVideoTimeRatio"))), - toDouble(row.get(indexMap.get("audienceWatchRatio"))), - now - )) - .toList(); - - double relativeRetentionAvg = response.rows().stream() - .mapToDouble(row -> toDouble(row.get(indexMap.get("relativeRetentionPerformance")))) - .average() - .orElse(0.0); - - result.add(new YoutubeAnalyticsSyncData.AudienceRetentionData( - video.videoId(), - retentions, - relativeRetentionAvg - )); - } catch (ApiException e) { - log.warn("Failed to sync audience retention. videoId={} youtubeVideoId={} startDate={} endDate={}", - video.videoId(), video.youtubeVideoId(), startDate, endDate, e); - continue; + YoutubeAnalyticsVideoRequest request = new YoutubeAnalyticsVideoRequest( + startDate, + endDate, + VIDEO_RETENTION_METRICS, + video.youtubeVideoId(), + "elapsedVideoTimeRatio", + null + ); + try { + YoutubeAnalyticsVideoResponse response = youtubeAnalyticsApiClient.getYoutubeAnalytics(googleId, request); + if (response.rows() == null || response.rows().isEmpty()) { + return List.of(); } + + Map indexMap = buildIndexMap(response.columnHeaders()); + LocalDateTime now = LocalDateTime.now(); + + List retentions = response.rows().stream() + .map(row -> new YoutubeAnalyticsSyncData.AudienceRetentionPointData( + toDouble(row.get(indexMap.get("elapsedVideoTimeRatio"))), + toDouble(row.get(indexMap.get("audienceWatchRatio"))), + now + )) + .toList(); + + double relativeRetentionAvg = response.rows().stream() + .mapToDouble(row -> toDouble(row.get(indexMap.get("relativeRetentionPerformance")))) + .average() + .orElse(0.0); + + return List.of(new YoutubeAnalyticsSyncData.AudienceRetentionData( + video.videoId(), + retentions, + relativeRetentionAvg + )); + } catch (ApiException e) { + log.warn("Failed to sync audience retention. videoId={} youtubeVideoId={} startDate={} endDate={}", + video.videoId(), video.youtubeVideoId(), startDate, endDate, e); + return List.of(); } - return result; } private Map querySubscriberViews( @@ -564,6 +615,14 @@ private double round(double value, int scale) { .doubleValue(); } + private List> chunked(List values, int size) { + List> chunks = new ArrayList<>(); + for (int start = 0; start < values.size(); start += size) { + chunks.add(values.subList(start, Math.min(values.size(), start + size))); + } + return chunks; + } + private Map buildIndexMap(List headers) { return IntStream.range(0, headers.size()) .boxed() diff --git a/src/main/java/com/example/inflace/global/client/YoutubeAnalyticsApiClient.java b/src/main/java/com/example/inflace/global/client/YoutubeAnalyticsApiClient.java index e3e88d81..1ee95811 100644 --- a/src/main/java/com/example/inflace/global/client/YoutubeAnalyticsApiClient.java +++ b/src/main/java/com/example/inflace/global/client/YoutubeAnalyticsApiClient.java @@ -5,7 +5,10 @@ import com.example.inflace.domain.video.dto.YoutubeAnalyticsVideoResponse; import com.example.inflace.global.exception.ApiException; import com.example.inflace.global.exception.ErrorDefine; +import com.example.inflace.global.exception.ExternalApiLimitExceptionMapper; import com.example.inflace.global.properties.YoutubeProperties; +import io.github.resilience4j.bulkhead.annotation.Bulkhead; +import io.github.resilience4j.ratelimiter.annotation.RateLimiter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; @@ -30,6 +33,8 @@ public class YoutubeAnalyticsApiClient { private final YoutubeProperties youtubeProperties; private final GoogleOAuthTokenService googleOAuthTokenService; + @Bulkhead(name = "youtube-analytics", fallbackMethod = "youtubeAnalyticsFallback") + @RateLimiter(name = "youtube-analytics", fallbackMethod = "youtubeAnalyticsFallback") public YoutubeAnalyticsVideoResponse getYoutubeAnalytics(String googleId, YoutubeAnalyticsVideoRequest request) { Map requestLog = new LinkedHashMap<>(); requestLog.put("startDate", request.startDate()); @@ -90,4 +95,14 @@ public YoutubeAnalyticsVideoResponse getYoutubeAnalytics(String googleId, Youtub }) .body(YoutubeAnalyticsVideoResponse.class)); } + + private YoutubeAnalyticsVideoResponse youtubeAnalyticsFallback( + String googleId, + YoutubeAnalyticsVideoRequest request, + Throwable throwable + ) { + log.warn("YouTube Analytics API call blocked or failed before execution. googleId={} metrics={} dimensions={}", + googleId, request.formattedMetricsList(), request.dimensions(), throwable); + throw ExternalApiLimitExceptionMapper.toRuntimeException(throwable); + } } diff --git a/src/main/java/com/example/inflace/global/client/YoutubeDataApiClient.java b/src/main/java/com/example/inflace/global/client/YoutubeDataApiClient.java index dfa171c1..e2d6a1f9 100644 --- a/src/main/java/com/example/inflace/global/client/YoutubeDataApiClient.java +++ b/src/main/java/com/example/inflace/global/client/YoutubeDataApiClient.java @@ -3,7 +3,11 @@ import com.example.inflace.domain.channel.dto.response.YoutubeDataChannelResponse; import com.example.inflace.domain.auth.service.GoogleOAuthTokenService; import com.example.inflace.domain.video.dto.YoutubeDataVideoResponse; +import com.example.inflace.global.exception.ApiException; +import com.example.inflace.global.exception.ExternalApiLimitExceptionMapper; import com.example.inflace.global.properties.YoutubeProperties; +import io.github.resilience4j.bulkhead.annotation.Bulkhead; +import io.github.resilience4j.ratelimiter.annotation.RateLimiter; import java.net.URI; import java.util.ArrayList; import java.util.LinkedHashSet; @@ -29,6 +33,8 @@ public class YoutubeDataApiClient { private final YoutubeProperties youtubeProperties; private final GoogleOAuthTokenService googleOAuthTokenService; + @Bulkhead(name = "youtube-data", fallbackMethod = "youtubeDataChannelFallback") + @RateLimiter(name = "youtube-data", fallbackMethod = "youtubeDataChannelFallback") public YoutubeDataChannelResponse getYoutubeChannels(String channelId, String parts) { URI uri = UriComponentsBuilder .fromUriString(youtubeProperties.dataApi().baseUrl()) @@ -45,6 +51,8 @@ public YoutubeDataChannelResponse getYoutubeChannels(String channelId, String pa .body(YoutubeDataChannelResponse.class); } + @Bulkhead(name = "youtube-data", fallbackMethod = "youtubeDataChannelFallback") + @RateLimiter(name = "youtube-data", fallbackMethod = "youtubeDataChannelFallback") public YoutubeDataChannelResponse getMyChannel(String googleId, String parts) { URI uri = UriComponentsBuilder .fromUriString(youtubeProperties.dataApi().baseUrl()) @@ -61,6 +69,8 @@ public YoutubeDataChannelResponse getMyChannel(String googleId, String parts) { .body(YoutubeDataChannelResponse.class)); } + @Bulkhead(name = "youtube-data", fallbackMethod = "youtubeDataVideoFallback") + @RateLimiter(name = "youtube-data", fallbackMethod = "youtubeDataVideoFallback") public YoutubeDataVideoResponse getYoutubeVideo(String videoId, String parts) { URI uri = UriComponentsBuilder .fromUriString(youtubeProperties.dataApi().baseUrl()) @@ -77,6 +87,8 @@ public YoutubeDataVideoResponse getYoutubeVideo(String videoId, String parts) { .body(YoutubeDataVideoResponse.class); } + @Bulkhead(name = "youtube-data", fallbackMethod = "youtubeDataStringListFallback") + @RateLimiter(name = "youtube-data", fallbackMethod = "youtubeDataStringListFallback") public List getMyVideoIds(String googleId, String uploadsPlaylistId) { if (!StringUtils.hasText(uploadsPlaylistId)) { return List.of(); @@ -124,6 +136,8 @@ public List getMyVideoIds(String googleId, String uploadsPlaylistId) { return new ArrayList<>(videoIds); } + @Bulkhead(name = "youtube-data", fallbackMethod = "youtubeDataVideoItemListFallback") + @RateLimiter(name = "youtube-data", fallbackMethod = "youtubeDataVideoItemListFallback") public List getYoutubeVideos(List videoIds, String parts) { List normalizedVideoIds = normalizeIds(videoIds); if (normalizedVideoIds.isEmpty()) { @@ -174,6 +188,8 @@ private List> chunked(List values, int size) { } // https://developers.google.com/youtube/v3/docs/playlistItems/list + @Bulkhead(name = "youtube-data", fallbackMethod = "youtubeDataStringListFallback") + @RateLimiter(name = "youtube-data", fallbackMethod = "youtubeDataStringListFallback") public List getRecentUploadDates(String playlistId, int maxResults) { if (!StringUtils.hasText(playlistId)) { return List.of(); @@ -206,6 +222,34 @@ public List getRecentUploadDates(String playlistId, int maxResults) { .toList(); } + private YoutubeDataChannelResponse youtubeDataChannelFallback(String id, String parts, Throwable throwable) { + throw youtubeDataException(throwable); + } + + private YoutubeDataVideoResponse youtubeDataVideoFallback(String videoId, String parts, Throwable throwable) { + throw youtubeDataException(throwable); + } + + private List youtubeDataStringListFallback(String id, int maxResults, Throwable throwable) { + throw youtubeDataException(throwable); + } + + private List youtubeDataStringListFallback(String id, String value, Throwable throwable) { + throw youtubeDataException(throwable); + } + + private List youtubeDataVideoItemListFallback( + List videoIds, + String parts, + Throwable throwable + ) { + throw youtubeDataException(throwable); + } + + private ApiException youtubeDataException(Throwable throwable) { + throw ExternalApiLimitExceptionMapper.toRuntimeException(throwable); + } + public record PlaylistItemsResponse( String nextPageToken, List items diff --git a/src/main/java/com/example/inflace/global/client/YoutubeSearchApiClient.java b/src/main/java/com/example/inflace/global/client/YoutubeSearchApiClient.java index d6c87205..47b54285 100644 --- a/src/main/java/com/example/inflace/global/client/YoutubeSearchApiClient.java +++ b/src/main/java/com/example/inflace/global/client/YoutubeSearchApiClient.java @@ -1,6 +1,9 @@ package com.example.inflace.global.client; import com.example.inflace.global.properties.YoutubeProperties; +import com.example.inflace.global.exception.ExternalApiLimitExceptionMapper; +import io.github.resilience4j.bulkhead.annotation.Bulkhead; +import io.github.resilience4j.ratelimiter.annotation.RateLimiter; import java.net.URI; import java.util.List; import lombok.RequiredArgsConstructor; @@ -19,6 +22,8 @@ public class YoutubeSearchApiClient { private final YoutubeProperties youtubeProperties; // https://developers.google.com/youtube/v3/docs/search/list + @Bulkhead(name = "youtube-data", fallbackMethod = "searchFallback") + @RateLimiter(name = "youtube-data", fallbackMethod = "searchFallback") public YoutubeSearchListResponse search( String q, String pageToken, @@ -79,6 +84,23 @@ public YoutubeSearchListResponse search( .body(YoutubeSearchListResponse.class); } + private YoutubeSearchListResponse searchFallback( + String q, + String pageToken, + String order, + String videoDuration, + String categoryId, + String regionCode, + String relevanceLanguage, + int maxResults, + String publishedAfter, + String publishedBefore, + String channelId, + Throwable throwable + ) { + throw ExternalApiLimitExceptionMapper.toRuntimeException(throwable); + } + public record YoutubeSearchListResponse( String nextPageToken, List items diff --git a/src/main/java/com/example/inflace/global/config/ExternalApiExecutorConfig.java b/src/main/java/com/example/inflace/global/config/ExternalApiExecutorConfig.java new file mode 100644 index 00000000..2e4f0469 --- /dev/null +++ b/src/main/java/com/example/inflace/global/config/ExternalApiExecutorConfig.java @@ -0,0 +1,15 @@ +package com.example.inflace.global.config; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class ExternalApiExecutorConfig { + + @Bean(destroyMethod = "close") + public ExecutorService externalApiExecutor() { + return Executors.newVirtualThreadPerTaskExecutor(); + } +} diff --git a/src/main/java/com/example/inflace/global/config/RestClientConfig.java b/src/main/java/com/example/inflace/global/config/RestClientConfig.java index 5acb9ec5..fdb6c41d 100644 --- a/src/main/java/com/example/inflace/global/config/RestClientConfig.java +++ b/src/main/java/com/example/inflace/global/config/RestClientConfig.java @@ -1,14 +1,25 @@ package com.example.inflace.global.config; +import java.time.Duration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.web.client.RestClient; @Configuration public class RestClientConfig { + private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(2); + private static final Duration READ_TIMEOUT = Duration.ofSeconds(5); + @Bean - public RestClient restClient(){ - return RestClient.builder().build(); + public RestClient restClient() { + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(CONNECT_TIMEOUT); + requestFactory.setReadTimeout(READ_TIMEOUT); + + return RestClient.builder() + .requestFactory(requestFactory) + .build(); } } diff --git a/src/main/java/com/example/inflace/global/exception/ErrorDefine.java b/src/main/java/com/example/inflace/global/exception/ErrorDefine.java index fa8951ec..acdef981 100644 --- a/src/main/java/com/example/inflace/global/exception/ErrorDefine.java +++ b/src/main/java/com/example/inflace/global/exception/ErrorDefine.java @@ -42,6 +42,7 @@ public enum ErrorDefine { ANALYTICS_DATA_NOT_FOUND("ANALYTICS_404", HttpStatus.NOT_FOUND, "Not Found: Analytics data not found"), //ETC + EXTERNAL_API_RATE_LIMITED("EXTERNAL_429", HttpStatus.TOO_MANY_REQUESTS, "Too Many Requests: External API capacity is temporarily limited"), YOUTUBE_API_ERROR("YOUTUBE_500", HttpStatus.INTERNAL_SERVER_ERROR, "YouTube API Error"); private final String errorCode; diff --git a/src/main/java/com/example/inflace/global/exception/ExternalApiLimitExceptionMapper.java b/src/main/java/com/example/inflace/global/exception/ExternalApiLimitExceptionMapper.java new file mode 100644 index 00000000..c8b9a5cf --- /dev/null +++ b/src/main/java/com/example/inflace/global/exception/ExternalApiLimitExceptionMapper.java @@ -0,0 +1,23 @@ +package com.example.inflace.global.exception; + +import io.github.resilience4j.bulkhead.BulkheadFullException; +import io.github.resilience4j.ratelimiter.RequestNotPermitted; + +public final class ExternalApiLimitExceptionMapper { + + private ExternalApiLimitExceptionMapper() { + } + + public static RuntimeException toRuntimeException(Throwable throwable) { + if (throwable instanceof BulkheadFullException || throwable instanceof RequestNotPermitted) { + return new ApiException(ErrorDefine.EXTERNAL_API_RATE_LIMITED); + } + if (throwable instanceof ApiException apiException) { + return apiException; + } + if (throwable instanceof RuntimeException runtimeException) { + return runtimeException; + } + return new RuntimeException(throwable); + } +} diff --git a/src/main/java/com/example/inflace/infra/openai/service/OpenAiService.java b/src/main/java/com/example/inflace/infra/openai/service/OpenAiService.java index 9c90e810..21d45a81 100644 --- a/src/main/java/com/example/inflace/infra/openai/service/OpenAiService.java +++ b/src/main/java/com/example/inflace/infra/openai/service/OpenAiService.java @@ -1,6 +1,9 @@ package com.example.inflace.infra.openai.service; import com.example.inflace.infra.openai.OpenAiSendRequest; +import com.example.inflace.global.exception.ExternalApiLimitExceptionMapper; +import io.github.resilience4j.bulkhead.annotation.Bulkhead; +import io.github.resilience4j.ratelimiter.annotation.RateLimiter; import java.util.ArrayList; import java.util.List; import org.springframework.ai.chat.client.ChatClient; @@ -22,6 +25,8 @@ public OpenAiService(ChatClient.Builder chatClientBuilder) { this.openAiChatClient = chatClientBuilder.build(); } + @Bulkhead(name = "openai", fallbackMethod = "openAiFallback") + @RateLimiter(name = "openai", fallbackMethod = "openAiFallback") public String sendChatMessage(OpenAiSendRequest request) { List messages = new ArrayList<>(); @@ -46,4 +51,8 @@ public String sendChatMessage(OpenAiSendRequest request) { .call() .content(); } + + private String openAiFallback(OpenAiSendRequest request, Throwable throwable) { + throw ExternalApiLimitExceptionMapper.toRuntimeException(throwable); + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a69cd460..2b435ca1 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -59,3 +59,30 @@ aws: discord: error: webhook-url: ${DISCORD_ERROR_WEBHOOK_URL:} + +resilience4j: + bulkhead: + instances: + youtube-data: + max-concurrent-calls: ${YOUTUBE_DATA_BULKHEAD_MAX_CONCURRENT_CALLS:16} + max-wait-duration: ${YOUTUBE_DATA_BULKHEAD_MAX_WAIT_DURATION:500ms} + youtube-analytics: + max-concurrent-calls: ${YOUTUBE_ANALYTICS_BULKHEAD_MAX_CONCURRENT_CALLS:16} + max-wait-duration: ${YOUTUBE_ANALYTICS_BULKHEAD_MAX_WAIT_DURATION:1s} + openai: + max-concurrent-calls: ${OPENAI_BULKHEAD_MAX_CONCURRENT_CALLS:8} + max-wait-duration: ${OPENAI_BULKHEAD_MAX_WAIT_DURATION:1s} + ratelimiter: + instances: + youtube-data: + limit-for-period: ${YOUTUBE_DATA_RATE_LIMIT_FOR_PERIOD:20} + limit-refresh-period: ${YOUTUBE_DATA_RATE_LIMIT_REFRESH_PERIOD:1s} + timeout-duration: ${YOUTUBE_DATA_RATE_LIMIT_TIMEOUT:500ms} + youtube-analytics: + limit-for-period: ${YOUTUBE_ANALYTICS_RATE_LIMIT_FOR_PERIOD:32} + limit-refresh-period: ${YOUTUBE_ANALYTICS_RATE_LIMIT_REFRESH_PERIOD:1s} + timeout-duration: ${YOUTUBE_ANALYTICS_RATE_LIMIT_TIMEOUT:1s} + openai: + limit-for-period: ${OPENAI_RATE_LIMIT_FOR_PERIOD:8} + limit-refresh-period: ${OPENAI_RATE_LIMIT_REFRESH_PERIOD:1s} + timeout-duration: ${OPENAI_RATE_LIMIT_TIMEOUT:1s} diff --git a/src/test/java/com/example/inflace/domain/channel/service/sync/YoutubeChannelAnalyticsFetchServiceTest.java b/src/test/java/com/example/inflace/domain/channel/service/sync/YoutubeChannelAnalyticsFetchServiceTest.java new file mode 100644 index 00000000..4053cdc5 --- /dev/null +++ b/src/test/java/com/example/inflace/domain/channel/service/sync/YoutubeChannelAnalyticsFetchServiceTest.java @@ -0,0 +1,198 @@ +package com.example.inflace.domain.channel.service.sync; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.example.inflace.domain.channel.dto.sync.AnalyticsSyncContext; +import com.example.inflace.domain.channel.dto.sync.YoutubeAnalyticsSyncData; +import com.example.inflace.domain.video.dto.YoutubeAnalyticsVideoRequest; +import com.example.inflace.domain.video.dto.YoutubeAnalyticsVideoResponse; +import com.example.inflace.global.client.YoutubeAnalyticsApiClient; +import com.example.inflace.global.exception.ApiException; +import com.example.inflace.global.exception.ErrorDefine; +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class YoutubeChannelAnalyticsFetchServiceTest { + + private static final String GOOGLE_ID = "google-1"; + + private YoutubeAnalyticsApiClient youtubeAnalyticsApiClient; + private ExecutorService externalApiExecutor; + private YoutubeChannelAnalyticsFetchService service; + + @BeforeEach + void setUp() { + youtubeAnalyticsApiClient = mock(YoutubeAnalyticsApiClient.class); + externalApiExecutor = Executors.newVirtualThreadPerTaskExecutor(); + service = new YoutubeChannelAnalyticsFetchService(youtubeAnalyticsApiClient, externalApiExecutor); + } + + @AfterEach + void tearDown() { + externalApiExecutor.close(); + } + + @Test + void fetchAnalytics_runsVideoSummaryRequestsConcurrently() { + AtomicInteger activeVideoSummaryCalls = new AtomicInteger(); + AtomicInteger maxActiveVideoSummaryCalls = new AtomicInteger(); + stubAnalyticsApi(activeVideoSummaryCalls, maxActiveVideoSummaryCalls, false); + + YoutubeAnalyticsSyncData data = service.fetchAnalytics(GOOGLE_ID, contextWithVideos(4)); + + assertThat(maxActiveVideoSummaryCalls.get()) + .as("expected concurrent video analytics fetches but got sequential execution") + .isGreaterThan(1); + assertThat(data.videoAnalytics()) + .extracting(YoutubeAnalyticsSyncData.VideoAnalyticsData::videoId) + .containsExactly(1L, 2L, 3L, 4L); + } + + @Test + void fetchAnalytics_runsChannelAndSubscriberSectionsConcurrently() { + AtomicInteger activeCalls = new AtomicInteger(); + AtomicInteger maxActiveCalls = new AtomicInteger(); + when(youtubeAnalyticsApiClient.getYoutubeAnalytics(eq(GOOGLE_ID), any(YoutubeAnalyticsVideoRequest.class))) + .thenAnswer(invocation -> delayedEmptyResponse(activeCalls, maxActiveCalls)); + + service.fetchAnalytics(GOOGLE_ID, contextWithVideos(0)); + + assertThat(maxActiveCalls.get()) + .as("expected channel analytics and subscriber logs to run concurrently but got sequential execution") + .isGreaterThan(1); + } + + @Test + void fetchAnalytics_keepsPartialResultWhenOneVideoSummaryFails() { + stubAnalyticsApi(new AtomicInteger(), new AtomicInteger(), true); + + YoutubeAnalyticsSyncData data = service.fetchAnalytics(GOOGLE_ID, contextWithVideos(3)); + + assertThat(data.videoAnalytics()).hasSize(3); + assertThat(data.videoAnalytics()) + .filteredOn(result -> result.videoId().equals(2L)) + .singleElement() + .satisfies(result -> { + assertThat(result.updateSummary()).isTrue(); + assertThat(result.shareCount()).isNull(); + assertThat(result.unsubscribedViewCount()).isNull(); + }); + } + + private void stubAnalyticsApi( + AtomicInteger activeVideoSummaryCalls, + AtomicInteger maxActiveVideoSummaryCalls, + boolean failSecondVideoSummary + ) { + when(youtubeAnalyticsApiClient.getYoutubeAnalytics(eq(GOOGLE_ID), any(YoutubeAnalyticsVideoRequest.class))) + .thenAnswer(invocation -> { + YoutubeAnalyticsVideoRequest request = invocation.getArgument(1); + if ("video".equals(request.dimensions())) { + if (failSecondVideoSummary && "youtube-video-2".equals(request.youtubeVideoId())) { + throw new ApiException(ErrorDefine.YOUTUBE_API_ERROR); + } + return videoSummaryResponse(activeVideoSummaryCalls, maxActiveVideoSummaryCalls); + } + if ("subscribedStatus".equals(request.dimensions()) && request.youtubeVideoId() != null) { + return unsubscribedResponse(); + } + if ("elapsedVideoTimeRatio".equals(request.dimensions())) { + return retentionResponse(); + } + return emptyResponse(); + }); + } + + private YoutubeAnalyticsVideoResponse videoSummaryResponse( + AtomicInteger activeVideoSummaryCalls, + AtomicInteger maxActiveVideoSummaryCalls + ) throws InterruptedException { + int active = activeVideoSummaryCalls.incrementAndGet(); + maxActiveVideoSummaryCalls.accumulateAndGet(active, Math::max); + try { + Thread.sleep(80); + return new YoutubeAnalyticsVideoResponse( + "youtubeAnalytics#resultTable", + List.of( + header("shares"), + header("averageViewDuration"), + header("averageViewPercentage"), + header("subscribersGained"), + header("annotationClickThroughRate") + ), + List.of(List.of(10, 20.0, 30.0, 40, 50.0)) + ); + } finally { + activeVideoSummaryCalls.decrementAndGet(); + } + } + + private YoutubeAnalyticsVideoResponse delayedEmptyResponse( + AtomicInteger activeCalls, + AtomicInteger maxActiveCalls + ) throws InterruptedException { + int active = activeCalls.incrementAndGet(); + maxActiveCalls.accumulateAndGet(active, Math::max); + try { + Thread.sleep(80); + return emptyResponse(); + } finally { + activeCalls.decrementAndGet(); + } + } + + private YoutubeAnalyticsVideoResponse unsubscribedResponse() { + return new YoutubeAnalyticsVideoResponse( + "youtubeAnalytics#resultTable", + List.of(header("subscribedStatus"), header("views")), + List.of(List.of("UNSUBSCRIBED", 5)) + ); + } + + private YoutubeAnalyticsVideoResponse retentionResponse() { + return new YoutubeAnalyticsVideoResponse( + "youtubeAnalytics#resultTable", + List.of(header("elapsedVideoTimeRatio"), header("audienceWatchRatio"), header("relativeRetentionPerformance")), + List.of(List.of(0.1, 0.9, 1.1)) + ); + } + + private YoutubeAnalyticsVideoResponse emptyResponse() { + return new YoutubeAnalyticsVideoResponse("youtubeAnalytics#resultTable", List.of(), List.of()); + } + + private YoutubeAnalyticsVideoResponse.ColumnHeader header(String name) { + return new YoutubeAnalyticsVideoResponse.ColumnHeader(name, "INTEGER", "METRIC"); + } + + private AnalyticsSyncContext contextWithVideos(int videoCount) { + List videos = java.util.stream.IntStream.rangeClosed(1, videoCount) + .mapToObj(index -> new AnalyticsSyncContext.VideoContext( + (long) index, + "youtube-video-" + index, + LocalDateTime.now().minusDays(10), + false, + 100L + )) + .toList(); + + return new AnalyticsSyncContext( + 1L, + "youtube-channel-1", + LocalDateTime.now().minusYears(1), + 1000L, + null, + videos + ); + } +} diff --git a/src/test/java/com/example/inflace/domain/video/service/VideoServiceTest.java b/src/test/java/com/example/inflace/domain/video/service/VideoServiceTest.java index 6a99ee8e..44a62da4 100644 --- a/src/test/java/com/example/inflace/domain/video/service/VideoServiceTest.java +++ b/src/test/java/com/example/inflace/domain/video/service/VideoServiceTest.java @@ -2,6 +2,7 @@ import com.example.inflace.domain.channel.domain.Channel; import com.example.inflace.domain.user.domain.entity.User; +import com.example.inflace.domain.user.domain.enums.Plan; import com.example.inflace.domain.video.domain.AudienceRetention; import com.example.inflace.domain.video.domain.Video; import com.example.inflace.domain.video.dto.AudienceRetentionResponse; @@ -9,8 +10,10 @@ import com.example.inflace.domain.video.repository.AudienceRetentionRepository; import com.example.inflace.domain.video.repository.VideoRepository; import com.example.inflace.domain.video.repository.VideoStatsRepository; +import com.example.inflace.global.config.AuthUser; import com.example.inflace.global.exception.ApiException; import com.example.inflace.global.exception.ErrorDefine; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayNameGeneration; import org.junit.jupiter.api.DisplayNameGenerator; @@ -19,12 +22,15 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.test.util.ReflectionTestUtils; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; import java.util.Optional; +import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -49,16 +55,22 @@ class VideoServiceTest { private Video video; private List retentionList; - private static final long OWNER_USER_ID = 1L; - private static final long OTHER_USER_ID = 2L; + private static final UUID OWNER_USER_ID = UUID.fromString("00000000-0000-0000-0000-000000000001"); + private static final UUID OTHER_USER_ID = UUID.fromString("00000000-0000-0000-0000-000000000002"); private static final Long VIDEO_ID = 1L; - private static final double DURATION = 600.0; // 10분 + private static final int DURATION_SECONDS = 600; // 10분 @BeforeEach void setUp() { - User user = User.builder() - .name("테스트유저") - .build(); + authenticate(OWNER_USER_ID); + User user = User.of( + "테스트유저", + null, + "test@example.com", + "test@example.com", + "provider-1", + Plan.FREE + ); ReflectionTestUtils.setField(user, "id", OWNER_USER_ID); Channel channel = Channel.builder() @@ -69,13 +81,24 @@ void setUp() { video = Video.builder() .channel(channel) .title("테스트영상") - .duration(DURATION) + .durationSeconds(DURATION_SECONDS) .publishedAt(LocalDateTime.now().minusDays(30)) .build(); retentionList = createRetentionList(); } + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + private void authenticate(UUID userId) { + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(new AuthUser(userId), null, List.of()) + ); + } + // 100개 retention 데이터 생성 (timeRatio 0.01~1.00, 균등 감소) // setUp()용 - 구간 구분 없이 0.5씩 균등 감소 private List createRetentionList() { @@ -120,7 +143,7 @@ private List createRetentionListWithSteps(double[] segmentSte given(audienceRetentionRepository.findByVideoIdOrderByTimeRatioAsc(VIDEO_ID)).willReturn(retentionList); // when - DropPointsResponse response = videoService.getDropPoints(OWNER_USER_ID, VIDEO_ID); + DropPointsResponse response = videoService.getDropPoints(VIDEO_ID); // then assertThat(response.dropPoints()).hasSize(4); @@ -133,7 +156,7 @@ private List createRetentionListWithSteps(double[] segmentSte given(audienceRetentionRepository.findByVideoIdOrderByTimeRatioAsc(VIDEO_ID)).willReturn(retentionList); // when - DropPointsResponse response = videoService.getDropPoints(OWNER_USER_ID, VIDEO_ID); + DropPointsResponse response = videoService.getDropPoints(VIDEO_ID); // then DropPointsResponse.DropPoint lastSegment = response.dropPoints().get(3); @@ -147,7 +170,7 @@ private List createRetentionListWithSteps(double[] segmentSte given(audienceRetentionRepository.findByVideoIdOrderByTimeRatioAsc(VIDEO_ID)).willReturn(retentionList); // when - DropPointsResponse response = videoService.getDropPoints(OWNER_USER_ID, VIDEO_ID); + DropPointsResponse response = videoService.getDropPoints(VIDEO_ID); // then assertThat(response.dropPoints().get(0).endTime()).isNotNull(); @@ -162,7 +185,7 @@ private List createRetentionListWithSteps(double[] segmentSte given(audienceRetentionRepository.findByVideoIdOrderByTimeRatioAsc(VIDEO_ID)).willReturn(retentionList); // when - DropPointsResponse response = videoService.getDropPoints(OWNER_USER_ID, VIDEO_ID); + DropPointsResponse response = videoService.getDropPoints(VIDEO_ID); // then // 각 구간의 startTime이 이전 구간보다 뒤여야 함 @@ -186,7 +209,7 @@ private List createRetentionListWithSteps(double[] segmentSte given(audienceRetentionRepository.findByVideoIdOrderByTimeRatioAsc(VIDEO_ID)).willReturn(customList); // when - DropPointsResponse response = videoService.getDropPoints(OWNER_USER_ID, VIDEO_ID); + DropPointsResponse response = videoService.getDropPoints(VIDEO_ID); // then assertThat(response.dropPoints().get(0).dropRate()).isEqualTo(2.0); @@ -201,7 +224,7 @@ private List createRetentionListWithSteps(double[] segmentSte given(videoRepository.findById(VIDEO_ID)).willReturn(Optional.empty()); // when & then - assertThatThrownBy(() -> videoService.getDropPoints(OWNER_USER_ID, VIDEO_ID)) + assertThatThrownBy(() -> videoService.getDropPoints(VIDEO_ID)) .isInstanceOf(ApiException.class) .hasFieldOrPropertyWithValue("error", ErrorDefine.VIDEO_NOT_FOUND); } @@ -210,9 +233,10 @@ private List createRetentionListWithSteps(double[] segmentSte void 이탈_구간_조회_소유자_불일치이면_예외() { // given given(videoRepository.findById(VIDEO_ID)).willReturn(Optional.of(video)); + authenticate(OTHER_USER_ID); // when & then - assertThatThrownBy(() -> videoService.getDropPoints(OTHER_USER_ID, VIDEO_ID)) + assertThatThrownBy(() -> videoService.getDropPoints(VIDEO_ID)) .isInstanceOf(ApiException.class) .hasFieldOrPropertyWithValue("error", ErrorDefine.AUTH_FORBIDDEN); } @@ -224,7 +248,7 @@ private List createRetentionListWithSteps(double[] segmentSte given(audienceRetentionRepository.findByVideoIdOrderByTimeRatioAsc(VIDEO_ID)).willReturn(List.of()); // when & then - assertThatThrownBy(() -> videoService.getDropPoints(OWNER_USER_ID, VIDEO_ID)) + assertThatThrownBy(() -> videoService.getDropPoints(VIDEO_ID)) .isInstanceOf(ApiException.class) .hasFieldOrPropertyWithValue("error", ErrorDefine.ANALYTICS_DATA_NOT_FOUND); } @@ -237,7 +261,7 @@ private List createRetentionListWithSteps(double[] segmentSte given(audienceRetentionRepository.findByVideoIdOrderByTimeRatioAsc(VIDEO_ID)).willReturn(incompleteList); // when & then - assertThatThrownBy(() -> videoService.getDropPoints(OWNER_USER_ID, VIDEO_ID)) + assertThatThrownBy(() -> videoService.getDropPoints(VIDEO_ID)) .isInstanceOf(ApiException.class) .hasFieldOrPropertyWithValue("error", ErrorDefine.RETENTION_INVALID); } @@ -253,7 +277,7 @@ private List createRetentionListWithSteps(double[] segmentSte given(audienceRetentionRepository.findByVideoIdOrderByTimeRatioAsc(VIDEO_ID)).willReturn(list); // when - AudienceRetentionResponse response = videoService.getRetention(OWNER_USER_ID, VIDEO_ID); + AudienceRetentionResponse response = videoService.getRetention(VIDEO_ID); // then assertThat(response.retentionData().get(1).isDrop()).isTrue(); @@ -270,7 +294,7 @@ private List createRetentionListWithSteps(double[] segmentSte given(audienceRetentionRepository.findByVideoIdOrderByTimeRatioAsc(VIDEO_ID)).willReturn(list); // when - AudienceRetentionResponse response = videoService.getRetention(OWNER_USER_ID, VIDEO_ID); + AudienceRetentionResponse response = videoService.getRetention(VIDEO_ID); // then assertThat(response.retentionData().get(1).isDrop()).isFalse(); @@ -286,7 +310,7 @@ private List createRetentionListWithSteps(double[] segmentSte given(audienceRetentionRepository.findByVideoIdOrderByTimeRatioAsc(VIDEO_ID)).willReturn(list); // when - AudienceRetentionResponse response = videoService.getRetention(OWNER_USER_ID, VIDEO_ID); + AudienceRetentionResponse response = videoService.getRetention(VIDEO_ID); // then assertThat(response.retentionData().get(0).displayTime()).isEqualTo("5:00"); @@ -307,7 +331,7 @@ private List createRetentionListWithSteps(double[] segmentSte given(audienceRetentionRepository.findByVideoIdOrderByTimeRatioAsc(VIDEO_ID)).willReturn(list); // when - AudienceRetentionResponse response = videoService.getRetention(OWNER_USER_ID, VIDEO_ID); + AudienceRetentionResponse response = videoService.getRetention(VIDEO_ID); // then assertThat(response.retentionData().get(0).displayTime()).isEqualTo("0:00"); diff --git a/src/test/java/com/example/inflace/global/exception/ExternalApiLimitExceptionMapperTest.java b/src/test/java/com/example/inflace/global/exception/ExternalApiLimitExceptionMapperTest.java new file mode 100644 index 00000000..ad28d157 --- /dev/null +++ b/src/test/java/com/example/inflace/global/exception/ExternalApiLimitExceptionMapperTest.java @@ -0,0 +1,58 @@ +package com.example.inflace.global.exception; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.github.resilience4j.bulkhead.Bulkhead; +import io.github.resilience4j.bulkhead.BulkheadFullException; +import io.github.resilience4j.ratelimiter.RateLimiter; +import io.github.resilience4j.ratelimiter.RequestNotPermitted; +import org.junit.jupiter.api.Test; + +class ExternalApiLimitExceptionMapperTest { + + @Test + void bulkheadFullException_mapsToExternalApiRateLimited() { + RuntimeException result = ExternalApiLimitExceptionMapper.toRuntimeException( + BulkheadFullException.createBulkheadFullException(Bulkhead.ofDefaults("youtube-analytics")) + ); + + assertThat(result) + .as("expected bulkhead permit exhaustion to be exposed as external API 429") + .isInstanceOfSatisfying(ApiException.class, exception -> + assertThat(exception.getError()).isEqualTo(ErrorDefine.EXTERNAL_API_RATE_LIMITED)); + } + + @Test + void requestNotPermitted_mapsToExternalApiRateLimited() { + RuntimeException result = ExternalApiLimitExceptionMapper.toRuntimeException( + RequestNotPermitted.createRequestNotPermitted(RateLimiter.ofDefaults("youtube-analytics")) + ); + + assertThat(result) + .as("expected rate limiter permit exhaustion to be exposed as external API 429") + .isInstanceOfSatisfying(ApiException.class, exception -> + assertThat(exception.getError()).isEqualTo(ErrorDefine.EXTERNAL_API_RATE_LIMITED)); + } + + @Test + void apiException_isPreserved() { + ApiException source = new ApiException(ErrorDefine.YOUTUBE_API_ERROR); + + RuntimeException result = ExternalApiLimitExceptionMapper.toRuntimeException(source); + + assertThat(result) + .as("expected existing ApiException to keep its original error definition") + .isSameAs(source); + } + + @Test + void runtimeException_isPreserved() { + IllegalStateException source = new IllegalStateException("unexpected failure"); + + RuntimeException result = ExternalApiLimitExceptionMapper.toRuntimeException(source); + + assertThat(result) + .as("expected non-limit runtime exception not to be mislabeled as 429") + .isSameAs(source); + } +} diff --git a/src/test/java/com/example/inflace/global/util/AnalyticsCalculatorTest.java b/src/test/java/com/example/inflace/global/util/AnalyticsCalculatorTest.java index 236e8259..ea6b357e 100644 --- a/src/test/java/com/example/inflace/global/util/AnalyticsCalculatorTest.java +++ b/src/test/java/com/example/inflace/global/util/AnalyticsCalculatorTest.java @@ -244,7 +244,7 @@ class AnalyticsCalculatorTest { @DisplayName("시간 포맷 변환 - 분:초 정상 변환") void formatTime_분초_정상변환() { // 0.5 * 600 = 300s → "5:00" - String result = AnalyticsCalculator.formatTime(0.5, 600.0); + String result = AnalyticsCalculator.formatTime(0.5, 600); assertThat(result).isEqualTo("5:00"); } @@ -253,7 +253,7 @@ class AnalyticsCalculatorTest { @DisplayName("시간 포맷 변환 - 초가 한 자리면 0 패딩") void formatTime_초한자리이면_0패딩() { // 0.01 * 600 = 6s → "0:06" - String result = AnalyticsCalculator.formatTime(0.01, 600.0); + String result = AnalyticsCalculator.formatTime(0.01, 600); assertThat(result).isEqualTo("0:06"); } @@ -262,7 +262,7 @@ class AnalyticsCalculatorTest { @DisplayName("시간 포맷 변환 - 정확히 분 단위이면 초 00") void formatTime_정확히분단위이면_초00() { // 0.1 * 600 = 60s → "1:00" - String result = AnalyticsCalculator.formatTime(0.1, 600.0); + String result = AnalyticsCalculator.formatTime(0.1, 600); assertThat(result).isEqualTo("1:00"); } @@ -271,8 +271,8 @@ class AnalyticsCalculatorTest { @DisplayName("시간 포맷 변환 - 반올림 적용") void formatTime_반올림적용() { // 0.25 * 363 = 90.75 → round → 91s → "1:31" - String result = AnalyticsCalculator.formatTime(0.25, 363.0); + String result = AnalyticsCalculator.formatTime(0.25, 363); assertThat(result).isEqualTo("1:31"); } -} \ No newline at end of file +}