diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index dcd5e43..7d1f2c1 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -178,6 +178,7 @@ type AuditLog = { targetId: number | null; message: string; details: string | null; + provider: string | null; success: boolean; createdAt: string | null; }; @@ -192,6 +193,11 @@ type SyncStatus = { failureCount: number | null; lastErrorMessage: string | null; status: string | null; + provider: string | null; + lastOperation: string | null; + lastErrorCategory: string | null; + lastHttpStatus: number | null; + lastAttemptCount: number | null; }; type CoverageStatus = "loading" | "ready" | "error"; @@ -1166,6 +1172,9 @@ export function AdminPage({ authState }: AdminPageProps) { const syncStatusByTask = new Map(syncStatuses.map((status) => [status.task, status])); const apiFootballStatus = syncStatusByTask.get("api-football"); + const newsProviderStatuses = [syncStatusByTask.get("serp-api"), syncStatusByTask.get("openai")].filter( + (status): status is SyncStatus => status !== undefined, + ); const activeSyncTasks = new Set(syncJobs.filter((job) => job.active).map((job) => job.task)); const runningSyncJobs = syncJobs.filter((job) => job.status === "RUNNING" || job.status === "CANCEL_REQUESTED"); const queuedSyncJobs = syncJobs.filter((job) => job.status === "QUEUED"); @@ -1441,8 +1450,8 @@ export function AdminPage({ authState }: AdminPageProps) {
- API-Football - Manual Sync + External APIs + Provider Status & API-Football Sync

@@ -1460,9 +1469,32 @@ export function AdminPage({ authState }: AdminPageProps) { 마지막 시도: {formatDateTime(apiFootballStatus?.lastAttemptAt ?? null)} {apiFootballStatus?.lastFailureAt ? 마지막 실패: {formatDateTime(apiFootballStatus.lastFailureAt)} : null} {(apiFootballStatus?.failureCount ?? 0) > 0 ? 기록된 장애: {apiFootballStatus?.failureCount}회 : null} + {apiFootballStatus?.lastOperation ? 작업: {apiFootballStatus.lastOperation} : null} + {apiFootballStatus?.lastAttemptCount ? 시도 횟수: {apiFootballStatus.lastAttemptCount} : null} + {apiFootballStatus?.lastErrorCategory ? 오류: {apiFootballStatus.lastErrorCategory}{apiFootballStatus.lastHttpStatus ? ` (${apiFootballStatus.lastHttpStatus})` : ""} : null} {apiFootballStatus?.lastErrorMessage ?

{apiFootballStatus.lastErrorMessage}

: null} + {newsProviderStatuses.map((status) => ( +
+
+ {status.label} 상태 + + {apiFootballStatusLabel(status.status)} + +
+
+ 마지막 성공: {formatDateTime(status.lastSuccessAt)} + 마지막 시도: {formatDateTime(status.lastAttemptAt)} + {status.lastFailureAt ? 마지막 실패: {formatDateTime(status.lastFailureAt)} : null} + 연속 실패: {status.failureCount ?? 0}회 + {status.lastOperation ? 작업: {status.lastOperation} : null} + {status.lastAttemptCount ? 시도 횟수: {status.lastAttemptCount} : null} + {status.lastErrorCategory ? 오류: {status.lastErrorCategory}{status.lastHttpStatus ? ` (${status.lastHttpStatus})` : ""} : null} +
+ {status.lastErrorMessage ?

{status.lastErrorMessage}

: null} +
+ ))}
{syncTasks.map((item) => { const status = syncStatusByTask.get(item.task); @@ -1569,6 +1601,7 @@ export function AdminPage({ authState }: AdminPageProps) {
{log.type} + {log.provider ? {externalApiProviderLabel(log.provider)} : null} {log.syncCategory ? {log.syncCategory} : null}
@@ -2664,3 +2697,10 @@ function formatFixtureScore(fixture: FixtureSummaryAdmin) { function labelize(value: string) { return value.replace(/[A-Z]/g, (letter) => ` ${letter}`).replace(/^./, (letter) => letter.toUpperCase()); } + +function externalApiProviderLabel(provider: string) { + if (provider === "SERP_API") return "SerpAPI"; + if (provider === "OPENAI") return "OpenAI"; + if (provider === "API_FOOTBALL") return "API-Football"; + return provider; +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index e914101..8aa2ba2 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -5056,6 +5056,11 @@ h2 { color: #9b2418; font-size: 0.8rem; font-weight: 800; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; + overflow: hidden; overflow-wrap: anywhere; } diff --git a/src/main/java/com/son/soccerStreaming/admin/dto/AdminDto.java b/src/main/java/com/son/soccerStreaming/admin/dto/AdminDto.java index ed640f9..5a3d511 100644 --- a/src/main/java/com/son/soccerStreaming/admin/dto/AdminDto.java +++ b/src/main/java/com/son/soccerStreaming/admin/dto/AdminDto.java @@ -504,6 +504,7 @@ public static class AuditLogResponse { private Long targetId; private String message; private String details; + private String provider; private boolean success; private LocalDateTime createdAt; } @@ -536,5 +537,10 @@ public static class SyncStatusItem { private Integer failureCount; private String lastErrorMessage; private String status; + private String provider; + private String lastOperation; + private String lastErrorCategory; + private Integer lastHttpStatus; + private Integer lastAttemptCount; } } diff --git a/src/main/java/com/son/soccerStreaming/admin/entity/AdminAuditLog.java b/src/main/java/com/son/soccerStreaming/admin/entity/AdminAuditLog.java index fc5887c..a7f0f03 100644 --- a/src/main/java/com/son/soccerStreaming/admin/entity/AdminAuditLog.java +++ b/src/main/java/com/son/soccerStreaming/admin/entity/AdminAuditLog.java @@ -43,6 +43,9 @@ public class AdminAuditLog { @Column(length = 4000) private String details; + @Column(length = 30) + private String provider; + @Column(nullable = false) private boolean success; @@ -67,4 +70,18 @@ public static AdminAuditLog of(AppUser adminUser, AdminAuditType type, String ta .createdAt(LocalDateTime.now()) .build(); } + + public static AdminAuditLog externalApiCall(AppUser adminUser, String provider, String message, + String details, boolean success) { + return AdminAuditLog.builder() + .adminUser(adminUser) + .type(AdminAuditType.EXTERNAL_API_CALL) + .targetType("EXTERNAL_API") + .message(message) + .details(details) + .provider(provider) + .success(success) + .createdAt(LocalDateTime.now()) + .build(); + } } diff --git a/src/main/java/com/son/soccerStreaming/admin/entity/AdminAuditType.java b/src/main/java/com/son/soccerStreaming/admin/entity/AdminAuditType.java index 1c8e8e1..bf868d6 100644 --- a/src/main/java/com/son/soccerStreaming/admin/entity/AdminAuditType.java +++ b/src/main/java/com/son/soccerStreaming/admin/entity/AdminAuditType.java @@ -7,5 +7,6 @@ public enum AdminAuditType { MEDIA_UPLOAD, MEDIA_RESTORE, OVERRIDE_CLEAR, - SYNC + SYNC, + EXTERNAL_API_CALL } diff --git a/src/main/java/com/son/soccerStreaming/admin/service/AdminService.java b/src/main/java/com/son/soccerStreaming/admin/service/AdminService.java index 2849cd2..6046dbd 100644 --- a/src/main/java/com/son/soccerStreaming/admin/service/AdminService.java +++ b/src/main/java/com/son/soccerStreaming/admin/service/AdminService.java @@ -112,6 +112,8 @@ public class AdminService { private static final Pattern SUBSTITUTION_DETAIL_PATTERN = Pattern.compile("Substitution\\s+\\d+"); private static final Pattern AUDIT_MESSAGE_PARAMETER_PATTERN = Pattern.compile("\\b(sequence|player|team)=(\\d+)\\b"); private static final Set PUBLIC_SYNC_DETAIL_KEYS = Set.of("league", "season", "fixtureId"); + private static final Set PUBLIC_EXTERNAL_API_DETAIL_KEYS = Set.of( + "operation", "teamId", "articleId", "batchSize", "resultCount", "attempts", "durationMs", "httpStatus", "errorCategory"); private static final Map SYNC_CATEGORY_LABELS = Map.of( "seasons", "Seasons", "teams", "Teams", @@ -125,6 +127,8 @@ public class AdminService { private static final List SYNC_STATUS_DEFINITIONS = List.of( new SyncStatusDefinition("api-football", "API-Football"), + new SyncStatusDefinition("serp-api", "SerpAPI"), + new SyncStatusDefinition("openai", "OpenAI"), new SyncStatusDefinition("seasons", "Seasons"), new SyncStatusDefinition("teams", "Teams"), new SyncStatusDefinition("standings", "Standings"), @@ -1311,6 +1315,7 @@ private AdminDto.AuditLogResponse toAuditLogResponse(AdminAuditLog log) { .targetId(log.getTargetId()) .message(publicAuditMessage(log)) .details(publicAuditDetails(log)) + .provider(log.getProvider()) .success(log.isSuccess()) .createdAt(log.getCreatedAt()) .build(); @@ -1341,6 +1346,9 @@ private String publicAuditDetails(AdminAuditLog log) { if (log.getType() == AdminAuditType.OVERRIDE_CLEAR) { addAllowedDetailParameter(parameters, log.getDetails(), "field", "field"); } + if (log.getType() == AdminAuditType.EXTERNAL_API_CALL) { + addWhitelistedParameters(parameters, log.getDetails(), PUBLIC_EXTERNAL_API_DETAIL_KEYS); + } if (parameters.isEmpty()) { return null; @@ -1398,6 +1406,16 @@ private void addFixtureAuditParameters(Map parameters, String me } } + private void addWhitelistedParameters(Map parameters, String details, Set allowedKeys) { + if (details == null || details.isBlank()) return; + for (String part : details.split(";")) { + String[] pair = part.trim().split("=", 2); + if (pair.length == 2 && allowedKeys.contains(pair[0]) && !pair[1].isBlank()) { + parameters.put(pair[0], pair[1].trim()); + } + } + } + private void addAllowedDetailParameter(Map parameters, String details, String sourceName, String outputName) { if (details == null || details.isBlank()) { @@ -1421,6 +1439,9 @@ private String publicAuditMessage(AdminAuditLog log) { case MEDIA_RESTORE -> "관리자 이미지가 원본으로 복원되었습니다."; case OVERRIDE_CLEAR -> "수동 수정 설정이 해제되었습니다."; case SYNC -> publicSyncAuditMessage(log); + case EXTERNAL_API_CALL -> log.isSuccess() + ? "외부 API 호출이 완료되었습니다." + : "외부 API 호출에 실패했습니다."; }; } @@ -1454,11 +1475,16 @@ private AdminDto.SyncStatusItem toSyncStatusItem(SyncStatusDefinition definition .failureCount(syncFailureCount(status)) .lastErrorMessage(status == null ? null : status.getLastErrorMessage()) .status(syncDisplayStatus(status)) + .provider(status == null ? null : status.getProvider()) + .lastOperation(status == null ? null : status.getLastOperation()) + .lastErrorCategory(status == null ? null : status.getLastErrorCategory()) + .lastHttpStatus(status == null ? null : status.getLastHttpStatus()) + .lastAttemptCount(status == null ? null : status.getLastAttemptCount()) .build(); } private String syncStatusKey(String task, Integer season) { - if ("api-football".equals(task)) { + if ("api-football".equals(task) || "serp-api".equals(task) || "openai".equals(task)) { return task; } if ("seasons".equals(task)) { diff --git a/src/main/java/com/son/soccerStreaming/admin/service/ExternalApiAuditLogListener.java b/src/main/java/com/son/soccerStreaming/admin/service/ExternalApiAuditLogListener.java new file mode 100644 index 0000000..98eb4ff --- /dev/null +++ b/src/main/java/com/son/soccerStreaming/admin/service/ExternalApiAuditLogListener.java @@ -0,0 +1,31 @@ +package com.son.soccerStreaming.admin.service; + +import com.son.soccerStreaming.global.externalapi.ExternalApiCallCompletedEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ExternalApiAuditLogListener { + private final ExternalApiAuditLogService auditLogService; + + @EventListener + public void onCompleted(ExternalApiCallCompletedEvent event) { + if (event.context() == null || !event.context().isAdminRequest()) return; + try { + auditLogService.record(event); + } catch (RuntimeException exception) { + log.atError() + .addKeyValue("event.action", "external-api-audit-persist") + .addKeyValue("event.outcome", "failure") + .addKeyValue("event.code", "EXTERNAL_API_AUDIT_PERSIST_FAILED") + .addKeyValue("external_api.provider", event.provider().name()) + .addKeyValue("external_api.operation", event.operation()) + .setCause(exception) + .log("Failed to persist external API admin audit."); + } + } +} diff --git a/src/main/java/com/son/soccerStreaming/admin/service/ExternalApiAuditLogService.java b/src/main/java/com/son/soccerStreaming/admin/service/ExternalApiAuditLogService.java new file mode 100644 index 0000000..bff63aa --- /dev/null +++ b/src/main/java/com/son/soccerStreaming/admin/service/ExternalApiAuditLogService.java @@ -0,0 +1,56 @@ +package com.son.soccerStreaming.admin.service; + +import com.son.soccerStreaming.admin.entity.AdminAuditLog; +import com.son.soccerStreaming.admin.repository.AdminAuditLogRepository; +import com.son.soccerStreaming.auth.repository.AppUserRepository; +import com.son.soccerStreaming.global.externalapi.ExternalApiCallCompletedEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; + +@Service +@Slf4j +@RequiredArgsConstructor +public class ExternalApiAuditLogService { + private final AppUserRepository appUserRepository; + private final AdminAuditLogRepository auditLogRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void record(ExternalApiCallCompletedEvent event) { + var context = event.context(); + if (context == null || !context.isAdminRequest()) return; + var admin = appUserRepository.findById(context.adminUserId()).orElse(null); + if (admin == null) { + log.atWarn() + .addKeyValue("event.action", "external-api-audit-persist") + .addKeyValue("event.outcome", "failure") + .addKeyValue("event.code", "EXTERNAL_API_AUDIT_ADMIN_NOT_FOUND") + .addKeyValue("external_api.provider", event.provider().name()) + .addKeyValue("external_api.operation", event.operation()) + .addKeyValue("user.id", context.adminUserId()) + .log("External API admin audit was skipped because the admin user was not found."); + return; + } + + List details = new ArrayList<>(); + details.add("operation=" + event.operation()); + if (context.teamId() != null) details.add("teamId=" + context.teamId()); + if (context.articleId() != null) details.add("articleId=" + context.articleId()); + if (context.batchSize() != null) details.add("batchSize=" + context.batchSize()); + if (event.resultCount() != null) details.add("resultCount=" + event.resultCount()); + details.add("attempts=" + event.attempts()); + details.add("durationMs=" + event.durationMs()); + if (event.httpStatus() != null) details.add("httpStatus=" + event.httpStatus()); + if (event.errorCategory() != null) details.add("errorCategory=" + event.errorCategory().name()); + + String message = event.provider().displayName() + " " + event.operation() + + (event.success() ? " call completed" : " call failed"); + auditLogRepository.save(AdminAuditLog.externalApiCall( + admin, event.provider().name(), message, String.join("; ", details), event.success())); + } +} diff --git a/src/main/java/com/son/soccerStreaming/apifootball/client/ApiFootballCircuitBreaker.java b/src/main/java/com/son/soccerStreaming/apifootball/client/ApiFootballCircuitBreaker.java index 662e231..094c474 100644 --- a/src/main/java/com/son/soccerStreaming/apifootball/client/ApiFootballCircuitBreaker.java +++ b/src/main/java/com/son/soccerStreaming/apifootball/client/ApiFootballCircuitBreaker.java @@ -1,7 +1,7 @@ package com.son.soccerStreaming.apifootball.client; -import com.son.soccerStreaming.apifootball.service.ApiFootballSyncStatusService; -import lombok.RequiredArgsConstructor; +import com.son.soccerStreaming.global.externalapi.ExternalApiErrorCategory; +import com.son.soccerStreaming.global.externalapi.ExternalApiException; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @@ -12,13 +12,7 @@ @Slf4j @Component -@RequiredArgsConstructor public class ApiFootballCircuitBreaker { - - private static final String GLOBAL_SYNC_KEY = "api-football"; - private static final String GLOBAL_DISPLAY_NAME = "API-Football"; - - private final ApiFootballSyncStatusService syncStatusService; private final Clock clock = Clock.systemUTC(); private final AtomicInteger consecutiveFailures = new AtomicInteger(); private final AtomicLong openUntilEpochMs = new AtomicLong(); @@ -52,11 +46,12 @@ public void recordSuccess() { } consecutiveFailures.set(0); openUntilEpochMs.set(0L); - syncStatusService.recordSuccessByKey(GLOBAL_SYNC_KEY, GLOBAL_DISPLAY_NAME); } public void recordFailure(String operation, Exception exception) { - if (!enabled || exception instanceof ApiFootballCircuitOpenException) { + if (!enabled || exception instanceof ApiFootballCircuitOpenException + || exception instanceof ExternalApiException external + && external.getCategory() == ExternalApiErrorCategory.CIRCUIT_OPEN) { return; } int failures = consecutiveFailures.incrementAndGet(); @@ -68,7 +63,6 @@ public void recordFailure(String operation, Exception exception) { openUntilEpochMs.set(openUntil); log.warn("API-Football circuit opened. operation={}, failureCount={}, openUntilEpochMs={}", operation, failures, openUntil); - syncStatusService.recordFailureByKey(GLOBAL_SYNC_KEY, GLOBAL_DISPLAY_NAME, exception); } private int configuredFailureThreshold() { diff --git a/src/main/java/com/son/soccerStreaming/apifootball/client/ApiFootballClient.java b/src/main/java/com/son/soccerStreaming/apifootball/client/ApiFootballClient.java index bc192ff..da17442 100644 --- a/src/main/java/com/son/soccerStreaming/apifootball/client/ApiFootballClient.java +++ b/src/main/java/com/son/soccerStreaming/apifootball/client/ApiFootballClient.java @@ -9,27 +9,25 @@ import com.son.soccerStreaming.apifootball.dto.ApiFootballStandingDto; import com.son.soccerStreaming.apifootball.dto.ApiFootballTeamDto; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; +import com.son.soccerStreaming.global.externalapi.ExternalApiException; +import com.son.soccerStreaming.global.externalapi.ExternalApiExecutor; +import com.son.soccerStreaming.global.externalapi.ExternalApiProvider; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatusCode; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; -import org.springframework.web.client.RestClientException; -import org.springframework.web.client.RestClientResponseException; import java.util.List; -import java.util.function.Supplier; import java.util.stream.Collectors; @Component -@Slf4j @RequiredArgsConstructor public class ApiFootballClient { private final RestClient apiFootballRestClient; private final ApiFootballCircuitBreaker apiFootballCircuitBreaker; + private final ExternalApiExecutor externalApiExecutor; @Value("${live.api-football.base-url:https://v3.football.api-sports.io}") private String baseUrl; @@ -40,15 +38,6 @@ public class ApiFootballClient { @Value("${live.api-football.api-host:}") private String apiHost; - @Value("${live.api-football.retry.max-attempts:3}") - private int retryMaxAttempts; - - @Value("${live.api-football.retry.initial-delay-ms:500}") - private long retryInitialDelayMs; - - @Value("${live.api-football.retry.multiplier:2.0}") - private double retryMultiplier; - public List getFixture(Long fixtureId) { ApiFootballLiveDto.ApiResponse body = get( "getFixture", @@ -279,69 +268,21 @@ public List getStandings(Integer league } private T get(String operation, String path, ParameterizedTypeReference responseType, Object... uriVariables) { - return executeWithRetry(operation, () -> apiFootballRestClient.get() - .uri(baseUrl + path, uriVariables) - .headers(this::setApiHeaders) - .retrieve() - .body(responseType)); - } - - // Retry transient API-Football failures before the scheduler handles the final error. - private T executeWithRetry(String operation, Supplier request) { - int maxAttempts = Math.max(1, retryMaxAttempts); - long delayMs = Math.max(0, retryInitialDelayMs); - double multiplier = Math.max(1.0, retryMultiplier); - - for (int attempt = 1; attempt <= maxAttempts; attempt++) { - try { + try { + return externalApiExecutor.execute(ExternalApiProvider.API_FOOTBALL, operation, () -> { apiFootballCircuitBreaker.beforeRequest(operation); - T response = request.get(); + T response = apiFootballRestClient.get() + .uri(baseUrl + path, uriVariables) + .headers(this::setApiHeaders) + .retrieve() + .body(responseType); apiFootballCircuitBreaker.recordSuccess(); return response; - } catch (RestClientException e) { - if (attempt >= maxAttempts || !isRetryable(e)) { - apiFootballCircuitBreaker.recordFailure(operation, e); - throw e; - } - - log.warn("API-Football request failed. operation={}, attempt={}/{}, retryDelayMs={}, reason={}", - operation, attempt, maxAttempts, delayMs, e.getMessage()); - sleepBeforeRetry(delayMs); - delayMs = nextDelay(delayMs, multiplier); - } - } - - throw new IllegalStateException("API-Football retry loop ended unexpectedly. operation=" + operation); - } - - private boolean isRetryable(RestClientException e) { - if (e instanceof ApiFootballCircuitOpenException) { - return false; - } - if (e instanceof RestClientResponseException responseException) { - HttpStatusCode statusCode = responseException.getStatusCode(); - return statusCode.is5xxServerError() || statusCode.value() == 429; - } - return true; - } - - private void sleepBeforeRetry(long delayMs) { - if (delayMs <= 0) { - return; - } - try { - Thread.sleep(delayMs); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IllegalStateException("API-Football retry interrupted.", e); - } - } - - private long nextDelay(long delayMs, double multiplier) { - if (delayMs <= 0) { - return 0; + }); + } catch (ExternalApiException exception) { + apiFootballCircuitBreaker.recordFailure(operation, exception); + throw exception; } - return Math.round(delayMs * multiplier); } private void setApiHeaders(HttpHeaders headers) { diff --git a/src/main/java/com/son/soccerStreaming/apifootball/config/ApiFootballClientConfig.java b/src/main/java/com/son/soccerStreaming/apifootball/config/ApiFootballClientConfig.java index 308f365..5dc6d00 100644 --- a/src/main/java/com/son/soccerStreaming/apifootball/config/ApiFootballClientConfig.java +++ b/src/main/java/com/son/soccerStreaming/apifootball/config/ApiFootballClientConfig.java @@ -3,12 +3,23 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.client.RestClient; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.client.JdkClientHttpRequestFactory; + +import java.net.http.HttpClient; +import java.time.Duration; @Configuration public class ApiFootballClientConfig { @Bean - public RestClient apiFootballRestClient() { - return RestClient.builder().build(); + public RestClient apiFootballRestClient( + @Value("${live.api-football.connect-timeout:3s}") Duration connectTimeout, + @Value("${live.api-football.request-timeout:15s}") Duration requestTimeout + ) { + HttpClient httpClient = HttpClient.newBuilder().connectTimeout(connectTimeout).build(); + JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient); + requestFactory.setReadTimeout(requestTimeout); + return RestClient.builder().requestFactory(requestFactory).build(); } } diff --git a/src/main/java/com/son/soccerStreaming/apifootball/entity/ApiFootballSyncStatus.java b/src/main/java/com/son/soccerStreaming/apifootball/entity/ApiFootballSyncStatus.java index 7277fec..08b3658 100644 --- a/src/main/java/com/son/soccerStreaming/apifootball/entity/ApiFootballSyncStatus.java +++ b/src/main/java/com/son/soccerStreaming/apifootball/entity/ApiFootballSyncStatus.java @@ -44,6 +44,19 @@ public class ApiFootballSyncStatus { @Column(length = 30) private String status; + @Column(length = 30) + private String provider; + + @Column(length = 100) + private String lastOperation; + + @Column(length = 30) + private String lastErrorCategory; + + private Integer lastHttpStatus; + + private Integer lastAttemptCount; + public void recordAttempt(String displayName, LocalDateTime attemptedAt) { this.displayName = displayName; this.lastAttemptAt = attemptedAt; @@ -82,4 +95,29 @@ public void recordRetryPending(String displayName, LocalDateTime scheduledAt, St this.lastErrorMessage = errorMessage; this.status = ApiFootballSyncState.RETRY_PENDING.name(); } + + public void recordProviderSuccess(String displayName, String provider, String operation, + int attemptCount, LocalDateTime succeededAt) { + recordSuccess(displayName, succeededAt); + this.provider = provider; + this.lastOperation = operation; + this.lastErrorCategory = null; + this.lastHttpStatus = null; + this.lastAttemptCount = attemptCount; + } + + public void recordProviderFailure(String displayName, String provider, String operation, + String errorCategory, Integer httpStatus, int attemptCount, + LocalDateTime failedAt, String errorMessage) { + recordFailure(displayName, failedAt, errorMessage); + this.provider = provider; + this.lastOperation = operation; + this.lastErrorCategory = errorCategory; + this.lastHttpStatus = httpStatus; + this.lastAttemptCount = attemptCount; + } + + public void markProvider(String provider) { + this.provider = provider; + } } diff --git a/src/main/java/com/son/soccerStreaming/apifootball/runner/ApiFootballFixtureDetailStartupSyncRunner.java b/src/main/java/com/son/soccerStreaming/apifootball/runner/ApiFootballFixtureDetailStartupSyncRunner.java index c4bea6c..403f068 100644 --- a/src/main/java/com/son/soccerStreaming/apifootball/runner/ApiFootballFixtureDetailStartupSyncRunner.java +++ b/src/main/java/com/son/soccerStreaming/apifootball/runner/ApiFootballFixtureDetailStartupSyncRunner.java @@ -39,6 +39,9 @@ public void run(String... args) { } private void scheduleRetry(Exception exception) { + if (!failureRetryScheduler.shouldRetry(exception)) { + return; + } if (exception instanceof ApiFootballFixtureDetailSyncException fixtureDetailException) { int index = 1; for (java.util.List chunk : fixtureDetailException.getFailedChunks()) { diff --git a/src/main/java/com/son/soccerStreaming/apifootball/runner/ApiFootballFixtureStartupSyncRunner.java b/src/main/java/com/son/soccerStreaming/apifootball/runner/ApiFootballFixtureStartupSyncRunner.java index 4011e6c..3f4be9f 100644 --- a/src/main/java/com/son/soccerStreaming/apifootball/runner/ApiFootballFixtureStartupSyncRunner.java +++ b/src/main/java/com/son/soccerStreaming/apifootball/runner/ApiFootballFixtureStartupSyncRunner.java @@ -35,6 +35,7 @@ public void run(String... args) { apiFootballFixtureSyncService.syncSeasonFixtures(league, season); } catch (Exception e) { log.error("API-Football startup fixture sync failed. league={}, season={}", league, season, e); + if (!failureRetryScheduler.shouldRetry(e)) return; failureRetryScheduler.schedule( "startup:fixtures:%s:%s".formatted(league, season), "startup fixture sync league=%s season=%s".formatted(league, season), diff --git a/src/main/java/com/son/soccerStreaming/apifootball/runner/ApiFootballInjuryStartupSyncRunner.java b/src/main/java/com/son/soccerStreaming/apifootball/runner/ApiFootballInjuryStartupSyncRunner.java index d1e5d09..d45ddec 100644 --- a/src/main/java/com/son/soccerStreaming/apifootball/runner/ApiFootballInjuryStartupSyncRunner.java +++ b/src/main/java/com/son/soccerStreaming/apifootball/runner/ApiFootballInjuryStartupSyncRunner.java @@ -35,6 +35,7 @@ public void run(String... args) { apiFootballInjurySyncService.syncInjuries(league, season); } catch (Exception e) { log.error("API-Football startup injury sync failed. league={}, season={}", league, season, e); + if (!failureRetryScheduler.shouldRetry(e)) return; failureRetryScheduler.schedule( "startup:injuries:%s:%s".formatted(league, season), "startup injury sync league=%s season=%s".formatted(league, season), diff --git a/src/main/java/com/son/soccerStreaming/apifootball/runner/ApiFootballRegisteredPlayerStartupSyncRunner.java b/src/main/java/com/son/soccerStreaming/apifootball/runner/ApiFootballRegisteredPlayerStartupSyncRunner.java index 2f64a1b..91138a6 100644 --- a/src/main/java/com/son/soccerStreaming/apifootball/runner/ApiFootballRegisteredPlayerStartupSyncRunner.java +++ b/src/main/java/com/son/soccerStreaming/apifootball/runner/ApiFootballRegisteredPlayerStartupSyncRunner.java @@ -44,6 +44,9 @@ public void run(String... args) { } private void scheduleRetry(Exception exception) { + if (!failureRetryScheduler.shouldRetry(exception)) { + return; + } if (exception instanceof ApiFootballRegisteredPlayerSyncException playerSyncException) { for (Long teamId : playerSyncException.getFailedTeamIds()) { failureRetryScheduler.schedule( diff --git a/src/main/java/com/son/soccerStreaming/apifootball/runner/ApiFootballStandingStartupSyncRunner.java b/src/main/java/com/son/soccerStreaming/apifootball/runner/ApiFootballStandingStartupSyncRunner.java index b504131..96053fc 100644 --- a/src/main/java/com/son/soccerStreaming/apifootball/runner/ApiFootballStandingStartupSyncRunner.java +++ b/src/main/java/com/son/soccerStreaming/apifootball/runner/ApiFootballStandingStartupSyncRunner.java @@ -35,6 +35,7 @@ public void run(String... args) { apiFootballStandingSyncService.syncStandings(league, season); } catch (Exception e) { log.error("API-Football startup standing sync failed. league={}, season={}", league, season, e); + if (!failureRetryScheduler.shouldRetry(e)) return; failureRetryScheduler.schedule( "startup:standings:%s:%s".formatted(league, season), "startup standing sync league=%s season=%s".formatted(league, season), diff --git a/src/main/java/com/son/soccerStreaming/apifootball/runner/ApiFootballTeamStartupSyncRunner.java b/src/main/java/com/son/soccerStreaming/apifootball/runner/ApiFootballTeamStartupSyncRunner.java index 5171151..472b7e6 100644 --- a/src/main/java/com/son/soccerStreaming/apifootball/runner/ApiFootballTeamStartupSyncRunner.java +++ b/src/main/java/com/son/soccerStreaming/apifootball/runner/ApiFootballTeamStartupSyncRunner.java @@ -35,6 +35,7 @@ public void run(String... args) { apiFootballTeamSyncService.syncTeams(league, season); } catch (Exception e) { log.error("API-Football startup team sync failed. league={}, season={}", league, season, e); + if (!failureRetryScheduler.shouldRetry(e)) return; failureRetryScheduler.schedule( "startup:teams:%s:%s".formatted(league, season), "startup team sync league=%s season=%s".formatted(league, season), diff --git a/src/main/java/com/son/soccerStreaming/apifootball/runner/LeagueSeasonCoverageStartupSyncRunner.java b/src/main/java/com/son/soccerStreaming/apifootball/runner/LeagueSeasonCoverageStartupSyncRunner.java index 19e87a4..873796e 100644 --- a/src/main/java/com/son/soccerStreaming/apifootball/runner/LeagueSeasonCoverageStartupSyncRunner.java +++ b/src/main/java/com/son/soccerStreaming/apifootball/runner/LeagueSeasonCoverageStartupSyncRunner.java @@ -30,6 +30,7 @@ public void run(String... args) { leagueSeasonCoverageSyncService.syncLeagueSeasons(league); } catch (Exception e) { log.error("API-Football startup league season coverage sync failed. league={}", league, e); + if (!failureRetryScheduler.shouldRetry(e)) return; failureRetryScheduler.schedule( "startup:league-seasons:%s".formatted(league), "startup league season coverage sync league=%s".formatted(league), diff --git a/src/main/java/com/son/soccerStreaming/apifootball/scheduler/ApiFootballFixtureDetailSyncScheduler.java b/src/main/java/com/son/soccerStreaming/apifootball/scheduler/ApiFootballFixtureDetailSyncScheduler.java index 44924a5..f06e925 100644 --- a/src/main/java/com/son/soccerStreaming/apifootball/scheduler/ApiFootballFixtureDetailSyncScheduler.java +++ b/src/main/java/com/son/soccerStreaming/apifootball/scheduler/ApiFootballFixtureDetailSyncScheduler.java @@ -61,6 +61,9 @@ private void syncLiveFixtureDetailsNow() { } private void scheduleFixtureDetailRetry(String reason, boolean applyLiveStandingImpact, Exception exception) { + if (!failureRetryScheduler.shouldRetry(exception)) { + return; + } if (exception instanceof ApiFootballFixtureDetailSyncException fixtureDetailException) { int index = 1; for (java.util.List chunk : fixtureDetailException.getFailedChunks()) { diff --git a/src/main/java/com/son/soccerStreaming/apifootball/scheduler/ApiFootballFixtureSyncScheduler.java b/src/main/java/com/son/soccerStreaming/apifootball/scheduler/ApiFootballFixtureSyncScheduler.java index 3137a97..32875fb 100644 --- a/src/main/java/com/son/soccerStreaming/apifootball/scheduler/ApiFootballFixtureSyncScheduler.java +++ b/src/main/java/com/son/soccerStreaming/apifootball/scheduler/ApiFootballFixtureSyncScheduler.java @@ -52,6 +52,7 @@ public void syncLiveFixturesWhenMatchWindowOpen() { apiFootballFixtureSyncService.syncLiveFixtures(league, season); } catch (Exception e) { log.error("API-Football live fixture sync failed.", e); + if (!failureRetryScheduler.shouldRetry(e)) return; failureRetryScheduler.schedule( "fixtures:live:%s:%s".formatted(league, season), "live fixture sync league=%s season=%s".formatted(league, season), @@ -65,6 +66,7 @@ private void syncSeasonFixtures(String reason) { apiFootballFixtureSyncService.syncSeasonFixtures(league, season); } catch (Exception e) { log.error("API-Football season fixture sync failed. reason={}, league={}, season={}", reason, league, season, e); + if (!failureRetryScheduler.shouldRetry(e)) return; failureRetryScheduler.schedule( "fixtures:%s:%s:%s".formatted(reason, league, season), "season fixture sync reason=%s league=%s season=%s".formatted(reason, league, season), diff --git a/src/main/java/com/son/soccerStreaming/apifootball/scheduler/ApiFootballInjurySyncScheduler.java b/src/main/java/com/son/soccerStreaming/apifootball/scheduler/ApiFootballInjurySyncScheduler.java index d8d28e2..975796f 100644 --- a/src/main/java/com/son/soccerStreaming/apifootball/scheduler/ApiFootballInjurySyncScheduler.java +++ b/src/main/java/com/son/soccerStreaming/apifootball/scheduler/ApiFootballInjurySyncScheduler.java @@ -29,6 +29,7 @@ public void syncInjuriesDaily() { apiFootballInjurySyncService.syncInjuries(league, season); } catch (Exception e) { log.error("API-Football injury sync failed. league={}, season={}", league, season, e); + if (!failureRetryScheduler.shouldRetry(e)) return; failureRetryScheduler.schedule( "injuries:%s:%s".formatted(league, season), "injury sync league=%s season=%s".formatted(league, season), diff --git a/src/main/java/com/son/soccerStreaming/apifootball/scheduler/ApiFootballRegisteredPlayerSyncScheduler.java b/src/main/java/com/son/soccerStreaming/apifootball/scheduler/ApiFootballRegisteredPlayerSyncScheduler.java index 42188fe..7b80525 100644 --- a/src/main/java/com/son/soccerStreaming/apifootball/scheduler/ApiFootballRegisteredPlayerSyncScheduler.java +++ b/src/main/java/com/son/soccerStreaming/apifootball/scheduler/ApiFootballRegisteredPlayerSyncScheduler.java @@ -38,6 +38,9 @@ public void syncRegisteredPlayersDaily() { } private void scheduleRetry(Exception exception) { + if (!failureRetryScheduler.shouldRetry(exception)) { + return; + } if (exception instanceof ApiFootballRegisteredPlayerSyncException playerSyncException) { for (Long teamId : playerSyncException.getFailedTeamIds()) { failureRetryScheduler.schedule( diff --git a/src/main/java/com/son/soccerStreaming/apifootball/scheduler/ApiFootballStandingSyncScheduler.java b/src/main/java/com/son/soccerStreaming/apifootball/scheduler/ApiFootballStandingSyncScheduler.java index d1760ad..9eab486 100644 --- a/src/main/java/com/son/soccerStreaming/apifootball/scheduler/ApiFootballStandingSyncScheduler.java +++ b/src/main/java/com/son/soccerStreaming/apifootball/scheduler/ApiFootballStandingSyncScheduler.java @@ -43,6 +43,7 @@ private void syncStandings(String reason) { apiFootballStandingSyncService.syncStandings(league, season); } catch (Exception e) { log.error("API-Football standing sync failed. reason={}, league={}, season={}", reason, league, season, e); + if (!failureRetryScheduler.shouldRetry(e)) return; failureRetryScheduler.schedule( "standings:%s:%s:%s".formatted(reason, league, season), "standing sync reason=%s league=%s season=%s".formatted(reason, league, season), diff --git a/src/main/java/com/son/soccerStreaming/apifootball/scheduler/ApiFootballSyncFailureRetryScheduler.java b/src/main/java/com/son/soccerStreaming/apifootball/scheduler/ApiFootballSyncFailureRetryScheduler.java index d588207..e7f3f6a 100644 --- a/src/main/java/com/son/soccerStreaming/apifootball/scheduler/ApiFootballSyncFailureRetryScheduler.java +++ b/src/main/java/com/son/soccerStreaming/apifootball/scheduler/ApiFootballSyncFailureRetryScheduler.java @@ -12,6 +12,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import com.son.soccerStreaming.global.externalapi.ExternalApiException; @Slf4j @Component @@ -63,6 +64,17 @@ public void schedule(String key, String description, Runnable retryAction) { scheduleNext(key, newState); } + public boolean shouldRetry(Exception exception) { + Throwable current = exception; + while (current != null) { + if (current instanceof ExternalApiException externalApiException) { + return externalApiException.isRetryable(); + } + current = current.getCause(); + } + return true; + } + @PreDestroy public void shutdown() { retryExecutor.shutdownNow(); @@ -92,6 +104,14 @@ private void runRetry(String key) { log.info("API-Football sync failure retry succeeded. key={}, description={}, attempt={}/{}", key, state.description(), attempt, state.maxAttempts()); } catch (Exception e) { + if (!shouldRetry(e)) { + retryStates.remove(key); + syncStatusOfRetryKey(key).ifPresent(status -> + syncStatusService.recordFailureByKey(status.syncKey(), status.displayName(), e)); + log.error("API-Football sync failure retry stopped for a non-retryable error. key={}, description={}, attempt={}/{}", + key, state.description(), attempt, state.maxAttempts(), e); + return; + } if (attempt >= state.maxAttempts()) { retryStates.remove(key); syncStatusOfRetryKey(key).ifPresent(status -> diff --git a/src/main/java/com/son/soccerStreaming/apifootball/scheduler/ApiFootballTeamSyncScheduler.java b/src/main/java/com/son/soccerStreaming/apifootball/scheduler/ApiFootballTeamSyncScheduler.java index c7604fe..cfaa8f6 100644 --- a/src/main/java/com/son/soccerStreaming/apifootball/scheduler/ApiFootballTeamSyncScheduler.java +++ b/src/main/java/com/son/soccerStreaming/apifootball/scheduler/ApiFootballTeamSyncScheduler.java @@ -29,6 +29,7 @@ public void syncTeams() { apiFootballTeamSyncService.syncTeams(league, season); } catch (Exception e) { log.error("API-Football team sync failed. league={}, season={}", league, season, e); + if (!failureRetryScheduler.shouldRetry(e)) return; failureRetryScheduler.schedule( "teams:%s:%s".formatted(league, season), "team sync league=%s season=%s".formatted(league, season), diff --git a/src/main/java/com/son/soccerStreaming/apifootball/scheduler/LeagueSeasonCoverageSyncScheduler.java b/src/main/java/com/son/soccerStreaming/apifootball/scheduler/LeagueSeasonCoverageSyncScheduler.java index 70d8691..f1be2f9 100644 --- a/src/main/java/com/son/soccerStreaming/apifootball/scheduler/LeagueSeasonCoverageSyncScheduler.java +++ b/src/main/java/com/son/soccerStreaming/apifootball/scheduler/LeagueSeasonCoverageSyncScheduler.java @@ -26,6 +26,7 @@ public void syncLeagueSeasons() { leagueSeasonCoverageSyncService.syncLeagueSeasons(league); } catch (Exception e) { log.error("API-Football league season coverage sync failed. league={}", league, e); + if (!failureRetryScheduler.shouldRetry(e)) return; failureRetryScheduler.schedule( "league-seasons:%s".formatted(league), "league season coverage sync league=%s".formatted(league), diff --git a/src/main/java/com/son/soccerStreaming/apifootball/service/ApiFootballFixtureDetailSyncException.java b/src/main/java/com/son/soccerStreaming/apifootball/service/ApiFootballFixtureDetailSyncException.java index 02180de..1c01eed 100644 --- a/src/main/java/com/son/soccerStreaming/apifootball/service/ApiFootballFixtureDetailSyncException.java +++ b/src/main/java/com/son/soccerStreaming/apifootball/service/ApiFootballFixtureDetailSyncException.java @@ -6,9 +6,9 @@ public class ApiFootballFixtureDetailSyncException extends RuntimeException { private final List> failedChunks; - public ApiFootballFixtureDetailSyncException(List> failedChunks, int totalChunks) { + public ApiFootballFixtureDetailSyncException(List> failedChunks, int totalChunks, Throwable cause) { super("API-Football fixture detail sync failed. failedChunks=" - + failedChunks.size() + ", totalChunks=" + totalChunks); + + failedChunks.size() + ", totalChunks=" + totalChunks, cause); this.failedChunks = failedChunks.stream() .map(List::copyOf) .toList(); diff --git a/src/main/java/com/son/soccerStreaming/apifootball/service/ApiFootballFixtureDetailSyncService.java b/src/main/java/com/son/soccerStreaming/apifootball/service/ApiFootballFixtureDetailSyncService.java index 548b8cc..93cd281 100644 --- a/src/main/java/com/son/soccerStreaming/apifootball/service/ApiFootballFixtureDetailSyncService.java +++ b/src/main/java/com/son/soccerStreaming/apifootball/service/ApiFootballFixtureDetailSyncService.java @@ -142,6 +142,7 @@ private List syncFixtureDetailsByIdsWithResults( List results = new ArrayList<>(); List> chunks = chunks(fixtureIds); List> failedChunks = new ArrayList<>(); + Exception firstFailure = null; int processedUnits = 0; int successfulUnits = 0; int failedUnits = 0; @@ -200,6 +201,9 @@ private List syncFixtureDetailsByIdsWithResults( } catch (SyncCancelledException exception) { throw exception; } catch (Exception e) { + if (firstFailure == null) { + firstFailure = e; + } if (chunkWatch.isRunning()) { chunkWatch.stop(); } @@ -212,7 +216,7 @@ private List syncFixtureDetailsByIdsWithResults( } } if (!failedChunks.isEmpty()) { - throw new ApiFootballFixtureDetailSyncException(failedChunks, chunks.size()); + throw new ApiFootballFixtureDetailSyncException(failedChunks, chunks.size(), firstFailure); } long totalMs = (System.nanoTime() - startedAtNanos) / 1_000_000; log.info("API-Football fixture detail sync completed. requestedCount={}, processedCount={}, " diff --git a/src/main/java/com/son/soccerStreaming/apifootball/service/ApiFootballPlayerSyncService.java b/src/main/java/com/son/soccerStreaming/apifootball/service/ApiFootballPlayerSyncService.java index 230cb3e..b775aed 100644 --- a/src/main/java/com/son/soccerStreaming/apifootball/service/ApiFootballPlayerSyncService.java +++ b/src/main/java/com/son/soccerStreaming/apifootball/service/ApiFootballPlayerSyncService.java @@ -94,6 +94,7 @@ public int syncRegisteredPlayers(Integer league, Integer season, Long delayMs, apiFootballSyncStatusService.recordAttempt("players", "Players", season); int syncedCount = 0; List failedTeamIds = new java.util.ArrayList<>(); + Exception firstFailure = null; Set syncedPlayerIds = new LinkedHashSet<>(); List teams = seasonTeams(league, season); int processedTeams = 0; @@ -110,6 +111,9 @@ public int syncRegisteredPlayers(Integer league, Integer season, Long delayMs, } catch (SyncCancelledException exception) { throw exception; } catch (Exception e) { + if (firstFailure == null) { + firstFailure = e; + } failedTeamIds.add(team.getTeamId()); progressReporter.error("TEAM", String.valueOf(team.getTeamId()), e.getMessage()); log.error("API-Football registered players team sync failed. teamId={}, season={}", @@ -120,7 +124,7 @@ public int syncRegisteredPlayers(Integer league, Integer season, Long delayMs, } if (!failedTeamIds.isEmpty()) { - throw new ApiFootballRegisteredPlayerSyncException(failedTeamIds); + throw new ApiFootballRegisteredPlayerSyncException(failedTeamIds, firstFailure); } progressReporter.checkCancelled(); diff --git a/src/main/java/com/son/soccerStreaming/apifootball/service/ApiFootballRegisteredPlayerSyncException.java b/src/main/java/com/son/soccerStreaming/apifootball/service/ApiFootballRegisteredPlayerSyncException.java index c1d3452..4995eab 100644 --- a/src/main/java/com/son/soccerStreaming/apifootball/service/ApiFootballRegisteredPlayerSyncException.java +++ b/src/main/java/com/son/soccerStreaming/apifootball/service/ApiFootballRegisteredPlayerSyncException.java @@ -6,8 +6,8 @@ public class ApiFootballRegisteredPlayerSyncException extends RuntimeException { private final List failedTeamIds; - public ApiFootballRegisteredPlayerSyncException(List failedTeamIds) { - super("API-Football registered players sync failed. failedTeams=" + failedTeamIds.size()); + public ApiFootballRegisteredPlayerSyncException(List failedTeamIds, Throwable cause) { + super("API-Football registered players sync failed. failedTeams=" + failedTeamIds.size(), cause); this.failedTeamIds = List.copyOf(failedTeamIds); } diff --git a/src/main/java/com/son/soccerStreaming/apifootball/service/ApiFootballSyncStatusService.java b/src/main/java/com/son/soccerStreaming/apifootball/service/ApiFootballSyncStatusService.java index 25e339f..1e018c4 100644 --- a/src/main/java/com/son/soccerStreaming/apifootball/service/ApiFootballSyncStatusService.java +++ b/src/main/java/com/son/soccerStreaming/apifootball/service/ApiFootballSyncStatusService.java @@ -2,6 +2,8 @@ import com.son.soccerStreaming.apifootball.entity.ApiFootballSyncStatus; import com.son.soccerStreaming.apifootball.repository.ApiFootballSyncStatusRepository; +import com.son.soccerStreaming.global.externalapi.ExternalApiException; +import com.son.soccerStreaming.global.externalapi.ExternalApiProvider; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; @@ -29,6 +31,7 @@ public void recordAttempt(String syncKey, String displayName, Integer season) { LocalDateTime now = LocalDateTime.now(KOREA_ZONE); ApiFootballSyncStatus status = status(syncKey, displayName, season, now); status.recordAttempt(displayName(displayName, season), now); + status.markProvider(ExternalApiProvider.API_FOOTBALL.name()); apiFootballSyncStatusRepository.save(status); } @@ -42,6 +45,7 @@ public void recordSuccess(String syncKey, String displayName, Integer season) { LocalDateTime now = LocalDateTime.now(KOREA_ZONE); ApiFootballSyncStatus status = status(syncKey, displayName, season, now); status.recordSuccess(displayName(displayName, season), now); + status.markProvider(ExternalApiProvider.API_FOOTBALL.name()); apiFootballSyncStatusRepository.save(status); } @@ -50,6 +54,7 @@ public void recordSuccessByKey(String syncKey, String displayName) { LocalDateTime now = LocalDateTime.now(KOREA_ZONE); ApiFootballSyncStatus status = statusByKey(syncKey, displayName, now); status.recordSuccess(displayName, now); + status.markProvider(ExternalApiProvider.API_FOOTBALL.name()); apiFootballSyncStatusRepository.save(status); } @@ -58,6 +63,7 @@ public void recordFailure(String syncKey, String displayName, Integer season, Ex LocalDateTime now = LocalDateTime.now(KOREA_ZONE); ApiFootballSyncStatus status = status(syncKey, displayName, season, now); status.recordFailure(displayName(displayName, season), now, errorMessage(exception)); + status.markProvider(ExternalApiProvider.API_FOOTBALL.name()); apiFootballSyncStatusRepository.save(status); } @@ -66,6 +72,7 @@ public void recordFailureByKey(String syncKey, String displayName, Exception exc LocalDateTime now = LocalDateTime.now(KOREA_ZONE); ApiFootballSyncStatus status = statusByKey(syncKey, displayName, now); status.recordFailure(displayName, now, errorMessage(exception)); + status.markProvider(ExternalApiProvider.API_FOOTBALL.name()); apiFootballSyncStatusRepository.save(status); } @@ -74,6 +81,7 @@ public void recordRetryPendingByKey(String syncKey, String displayName, String m LocalDateTime now = LocalDateTime.now(KOREA_ZONE); ApiFootballSyncStatus status = statusByKey(syncKey, displayName, now); status.recordRetryPending(displayName, now, safeMessage(message)); + status.markProvider(ExternalApiProvider.API_FOOTBALL.name()); apiFootballSyncStatusRepository.save(status); } @@ -82,6 +90,25 @@ public Optional findByKey(String syncKey) { return apiFootballSyncStatusRepository.findById(syncKey); } + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void recordProviderSuccess(ExternalApiProvider provider, String operation, int attemptCount) { + LocalDateTime now = LocalDateTime.now(KOREA_ZONE); + ApiFootballSyncStatus status = statusByKey(provider.statusKey(), provider.displayName(), now); + status.recordProviderSuccess(provider.displayName(), provider.name(), operation, attemptCount, now); + apiFootballSyncStatusRepository.save(status); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void recordProviderFailure(ExternalApiProvider provider, String operation, int attemptCount, + ExternalApiException exception) { + LocalDateTime now = LocalDateTime.now(KOREA_ZONE); + ApiFootballSyncStatus status = statusByKey(provider.statusKey(), provider.displayName(), now); + status.recordProviderFailure(provider.displayName(), provider.name(), operation, + exception.getCategory().name(), exception.getHttpStatus(), attemptCount, now, + safeMessage(exception.getMessage())); + apiFootballSyncStatusRepository.save(status); + } + private ApiFootballSyncStatus status(String syncKey, String displayName, Integer season, LocalDateTime now) { return statusByKey(syncKey(syncKey, season), displayName(displayName, season), now); } diff --git a/src/main/java/com/son/soccerStreaming/global/externalapi/ExternalApiCallCompletedEvent.java b/src/main/java/com/son/soccerStreaming/global/externalapi/ExternalApiCallCompletedEvent.java new file mode 100644 index 0000000..bba8d02 --- /dev/null +++ b/src/main/java/com/son/soccerStreaming/global/externalapi/ExternalApiCallCompletedEvent.java @@ -0,0 +1,7 @@ +package com.son.soccerStreaming.global.externalapi; + +public record ExternalApiCallCompletedEvent( + ExternalApiProvider provider, String operation, ExternalApiInvocationContext context, + boolean success, int attempts, long durationMs, Integer resultCount, Integer httpStatus, + ExternalApiErrorCategory errorCategory) { +} diff --git a/src/main/java/com/son/soccerStreaming/global/externalapi/ExternalApiErrorCategory.java b/src/main/java/com/son/soccerStreaming/global/externalapi/ExternalApiErrorCategory.java new file mode 100644 index 0000000..3fc53dd --- /dev/null +++ b/src/main/java/com/son/soccerStreaming/global/externalapi/ExternalApiErrorCategory.java @@ -0,0 +1,7 @@ +package com.son.soccerStreaming.global.externalapi; + +public enum ExternalApiErrorCategory { + CONFIGURATION, BAD_REQUEST, AUTHENTICATION, PERMISSION, NOT_FOUND, + RATE_LIMITED, QUOTA_EXHAUSTED, TIMEOUT, NETWORK, UPSTREAM_SERVER, + INVALID_RESPONSE, CIRCUIT_OPEN, UNKNOWN +} diff --git a/src/main/java/com/son/soccerStreaming/global/externalapi/ExternalApiException.java b/src/main/java/com/son/soccerStreaming/global/externalapi/ExternalApiException.java new file mode 100644 index 0000000..eaf3d72 --- /dev/null +++ b/src/main/java/com/son/soccerStreaming/global/externalapi/ExternalApiException.java @@ -0,0 +1,26 @@ +package com.son.soccerStreaming.global.externalapi; + +import lombok.Getter; +import java.time.Duration; + +@Getter +public class ExternalApiException extends RuntimeException { + private final ExternalApiProvider provider; + private final String operation; + private final ExternalApiErrorCategory category; + private final Integer httpStatus; + private final boolean retryable; + private final Duration retryAfter; + + public ExternalApiException(ExternalApiProvider provider, String operation, + ExternalApiErrorCategory category, Integer httpStatus, + boolean retryable, Duration retryAfter, String message, Throwable cause) { + super(message, cause); + this.provider = provider; + this.operation = operation; + this.category = category; + this.httpStatus = httpStatus; + this.retryable = retryable; + this.retryAfter = retryAfter; + } +} diff --git a/src/main/java/com/son/soccerStreaming/global/externalapi/ExternalApiExecutor.java b/src/main/java/com/son/soccerStreaming/global/externalapi/ExternalApiExecutor.java new file mode 100644 index 0000000..41e0263 --- /dev/null +++ b/src/main/java/com/son/soccerStreaming/global/externalapi/ExternalApiExecutor.java @@ -0,0 +1,305 @@ +package com.son.soccerStreaming.global.externalapi; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import com.son.soccerStreaming.apifootball.client.ApiFootballCircuitOpenException; +import com.son.soccerStreaming.apifootball.service.ApiFootballSyncStatusService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.http.HttpHeaders; +import org.springframework.http.converter.HttpMessageConversionException; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestClientResponseException; + +import java.net.ConnectException; +import java.net.SocketTimeoutException; +import java.net.http.HttpConnectTimeoutException; +import java.net.http.HttpTimeoutException; +import java.time.Duration; +import java.time.Instant; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.Collection; +import java.util.Locale; +import java.util.concurrent.ThreadLocalRandom; +import java.util.function.Supplier; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ExternalApiExecutor { + private final ApiFootballSyncStatusService syncStatusService; + private final ApplicationEventPublisher eventPublisher; + private final ObjectMapper objectMapper; + + @Value("${external-api.retry.max-attempts:3}") private int maxAttempts; + @Value("${external-api.retry.initial-delay:500ms}") private Duration initialDelay; + @Value("${external-api.retry.max-delay:5s}") private Duration maxDelay; + @Value("${external-api.retry.max-retry-after:30s}") private Duration maxRetryAfter; + @Value("${external-api.retry.jitter-max:250ms}") private Duration jitterMax; + + public T execute(ExternalApiProvider provider, String operation, Supplier request) { + return execute(provider, operation, ExternalApiInvocationContext.system(), request); + } + + public T execute(ExternalApiProvider provider, String operation, + ExternalApiInvocationContext context, Supplier request) { + long startedAt = System.nanoTime(); + int configuredAttempts = Math.max(1, maxAttempts); + for (int attempt = 1; attempt <= configuredAttempts; attempt++) { + try { + T result = request.get(); + recordSuccess(provider, operation, attempt); + publish(provider, operation, context, true, attempt, startedAt, result, null); + return result; + } catch (Exception exception) { + ExternalApiException failure = classify(provider, operation, exception); + boolean retryAfterExceedsLimit = retryAfterExceedsLimit(failure); + if (!failure.isRetryable() || attempt >= configuredAttempts || retryAfterExceedsLimit) { + if (retryAfterExceedsLimit) { + log.atWarn() + .addKeyValue("event.action", "external-api-retry") + .addKeyValue("event.outcome", "skipped") + .addKeyValue("event.code", "EXTERNAL_API_RETRY_AFTER_EXCEEDS_LIMIT") + .addKeyValue("external_api.provider", provider.name()) + .addKeyValue("external_api.operation", operation) + .addKeyValue("external_api.retry_after_ms", failure.getRetryAfter().toMillis()) + .addKeyValue("external_api.max_retry_after_ms", maxRetryAfter.toMillis()) + .log("External API synchronous retry was skipped because Retry-After exceeds the wait limit."); + } + recordFailure(provider, operation, attempt, failure); + publish(provider, operation, context, false, attempt, startedAt, null, failure); + throw failure; + } + Duration delay = retryDelay(failure, attempt); + log.warn("External API request will retry. provider={}, operation={}, category={}, status={}, attempt={}/{}, retryDelayMs={}", + provider, operation, failure.getCategory(), failure.getHttpStatus(), + attempt, configuredAttempts, delay.toMillis()); + try { + sleep(delay, provider, operation); + } catch (ExternalApiException interrupted) { + recordFailure(provider, operation, attempt, interrupted); + publish(provider, operation, context, false, attempt, startedAt, null, interrupted); + throw interrupted; + } + } + } + throw new IllegalStateException("External API retry loop ended unexpectedly"); + } + + ExternalApiException classify(ExternalApiProvider provider, String operation, Exception exception) { + if (exception instanceof ExternalApiException externalApiException) return externalApiException; + if (exception instanceof ApiFootballCircuitOpenException) { + return failure(provider, operation, ExternalApiErrorCategory.CIRCUIT_OPEN, null, false, null, + "External API circuit is open", exception); + } + if (exception instanceof RestClientResponseException responseException) { + int status = responseException.getStatusCode().value(); + String body = responseException.getResponseBodyAsString(); + Duration retryAfter = retryAfter(responseException.getResponseHeaders()); + if (status == 429) { + boolean quota = isQuotaError(provider, body); + return failure(provider, operation, + quota ? ExternalApiErrorCategory.QUOTA_EXHAUSTED : ExternalApiErrorCategory.RATE_LIMITED, + status, !quota, retryAfter, + quota ? "External API quota is exhausted" : "External API rate limit was reached", exception); + } + if (status == 408 || (provider == ExternalApiProvider.API_FOOTBALL && status == 499)) { + return failure(provider, operation, ExternalApiErrorCategory.TIMEOUT, status, true, retryAfter, + "External API request timed out", exception); + } + if (status == 400) return failure(provider, operation, ExternalApiErrorCategory.BAD_REQUEST, status, false, null, "External API rejected the request", exception); + if (status == 401) return failure(provider, operation, ExternalApiErrorCategory.AUTHENTICATION, status, false, null, "External API authentication failed", exception); + if (status == 403) return failure(provider, operation, ExternalApiErrorCategory.PERMISSION, status, false, null, "External API permission was denied", exception); + if (status == 404) return failure(provider, operation, ExternalApiErrorCategory.NOT_FOUND, status, false, null, "External API resource was not found", exception); + if (status == 500 || status == 502 || status == 503 || status == 504) { + return failure(provider, operation, ExternalApiErrorCategory.UPSTREAM_SERVER, status, true, retryAfter, "External API server failed", exception); + } + return failure(provider, operation, ExternalApiErrorCategory.BAD_REQUEST, status, false, null, "External API returned an unsuccessful response", exception); + } + if (hasCause(exception, ConnectException.class) || hasCause(exception, HttpConnectTimeoutException.class)) { + return failure(provider, operation, ExternalApiErrorCategory.NETWORK, null, true, null, "External API connection failed", exception); + } + if (hasCause(exception, HttpTimeoutException.class) || hasCause(exception, SocketTimeoutException.class)) { + return failure(provider, operation, ExternalApiErrorCategory.TIMEOUT, null, true, null, "External API request timed out", exception); + } + if (hasCause(exception, HttpMessageConversionException.class)) { + return failure(provider, operation, ExternalApiErrorCategory.INVALID_RESPONSE, null, false, null, + "External API response was invalid", exception); + } + if (exception instanceof RestClientException) { + return failure(provider, operation, ExternalApiErrorCategory.NETWORK, null, true, null, "External API network request failed", exception); + } + return failure(provider, operation, ExternalApiErrorCategory.UNKNOWN, null, false, null, "External API call failed", exception); + } + + private ExternalApiException failure(ExternalApiProvider provider, String operation, + ExternalApiErrorCategory category, Integer status, + boolean retryable, Duration retryAfter, String message, Exception cause) { + return new ExternalApiException(provider, operation, category, status, retryable, retryAfter, message, cause); + } + + private boolean isQuotaError(ExternalApiProvider provider, String body) { + ProviderErrorDetails details = providerErrorDetails(provider, body); + if (provider == ExternalApiProvider.OPENAI) { + if (isOpenAiQuotaCode(details.code()) || isOpenAiQuotaCode(details.type())) { + return true; + } + return isQuotaMessage(details.message()); + } + if (provider == ExternalApiProvider.SERP_API) { + return details.message().contains("run out of searches") + || details.message().contains("no searches remaining"); + } + return false; + } + + private ProviderErrorDetails providerErrorDetails(ExternalApiProvider provider, String body) { + if (body == null || body.isBlank()) return ProviderErrorDetails.EMPTY; + try { + JsonNode root = objectMapper.readTree(body); + if (provider == ExternalApiProvider.OPENAI) { + JsonNode error = root.path("error"); + return new ProviderErrorDetails( + normalized(error.path("code").asText("")), + normalized(error.path("type").asText("")), + normalized(error.path("message").asText("")) + ); + } + if (provider == ExternalApiProvider.SERP_API) { + return new ProviderErrorDetails("", "", normalized(root.path("error").asText(""))); + } + } catch (RuntimeException parseFailure) { + log.atDebug() + .addKeyValue("event.action", "external-api-error-classification") + .addKeyValue("event.outcome", "failure") + .addKeyValue("event.code", "EXTERNAL_API_ERROR_BODY_PARSE_FAILED") + .addKeyValue("external_api.provider", provider.name()) + .log("External API error body could not be parsed for classification."); + } + return ProviderErrorDetails.EMPTY; + } + + private boolean isOpenAiQuotaCode(String value) { + return value.equals("insufficient_quota") + || value.equals("billing_hard_limit_reached") + || value.equals("billing_not_active") + || value.equals("credits_exhausted"); + } + + private boolean isQuotaMessage(String message) { + return message.contains("exceeded your current quota") + || message.contains("quota exhausted") + || message.contains("run out of credits") + || message.contains("credits exhausted"); + } + + private String normalized(String value) { + return value == null ? "" : value.trim().toLowerCase(Locale.ROOT); + } + + private Duration retryAfter(HttpHeaders headers) { + if (headers == null) return null; + String value = headers.getFirst(HttpHeaders.RETRY_AFTER); + if (value == null || value.isBlank()) return null; + try { + return Duration.ofSeconds(Math.max(0, Long.parseLong(value.trim()))); + } catch (NumberFormatException ignored) { + try { + ZonedDateTime retryAt = ZonedDateTime.parse(value, DateTimeFormatter.RFC_1123_DATE_TIME); + Duration delay = Duration.between(Instant.now(), retryAt.toInstant()); + return delay.isNegative() ? Duration.ZERO : delay; + } catch (DateTimeParseException ignoredDate) { + return null; + } + } + } + + private Duration retryDelay(ExternalApiException failure, int attempt) { + if (failure.getRetryAfter() != null) return failure.getRetryAfter(); + long exponential = Math.min(maxDelay.toMillis(), initialDelay.toMillis() * (1L << Math.min(attempt - 1, 20))); + long jitterBound = Math.max(0, jitterMax.toMillis()); + long jitter = jitterBound == 0 ? 0 : ThreadLocalRandom.current().nextLong(jitterBound + 1); + return Duration.ofMillis(Math.min(maxDelay.toMillis(), exponential + jitter)); + } + + private boolean retryAfterExceedsLimit(ExternalApiException failure) { + return failure.getRetryAfter() != null && failure.getRetryAfter().compareTo(maxRetryAfter) > 0; + } + + private void sleep(Duration delay, ExternalApiProvider provider, String operation) { + try { + Thread.sleep(Math.max(0, delay.toMillis())); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw failure(provider, operation, ExternalApiErrorCategory.NETWORK, null, false, null, + "External API retry was interrupted", exception); + } + } + + private boolean hasCause(Throwable throwable, Class type) { + for (Throwable current = throwable; current != null; current = current.getCause()) { + if (type.isInstance(current)) return true; + } + return false; + } + + private void recordSuccess(ExternalApiProvider provider, String operation, int attempts) { + try { + syncStatusService.recordProviderSuccess(provider, operation, attempts); + } catch (RuntimeException statusFailure) { + log.atError() + .addKeyValue("event.action", "external-api-status-persist") + .addKeyValue("event.outcome", "failure") + .addKeyValue("event.code", "EXTERNAL_API_STATUS_PERSIST_FAILED") + .addKeyValue("external_api.provider", provider.name()) + .addKeyValue("external_api.operation", operation) + .setCause(statusFailure) + .log("Failed to persist external API success status."); + } + } + + private void recordFailure(ExternalApiProvider provider, String operation, int attempts, + ExternalApiException failure) { + try { + syncStatusService.recordProviderFailure(provider, operation, attempts, failure); + } catch (RuntimeException statusFailure) { + log.atError() + .addKeyValue("event.action", "external-api-status-persist") + .addKeyValue("event.outcome", "failure") + .addKeyValue("event.code", "EXTERNAL_API_STATUS_PERSIST_FAILED") + .addKeyValue("external_api.provider", provider.name()) + .addKeyValue("external_api.operation", operation) + .setCause(statusFailure) + .log("Failed to persist external API failure status."); + } + } + + private void publish(ExternalApiProvider provider, String operation, ExternalApiInvocationContext context, + boolean success, int attempts, long startedAt, Object result, ExternalApiException failure) { + try { + eventPublisher.publishEvent(new ExternalApiCallCompletedEvent(provider, operation, context, success, + attempts, (System.nanoTime() - startedAt) / 1_000_000, + result instanceof Collection collection ? collection.size() : null, + failure == null ? null : failure.getHttpStatus(), + failure == null ? null : failure.getCategory())); + } catch (RuntimeException eventFailure) { + log.atError() + .addKeyValue("event.action", "external-api-event-publish") + .addKeyValue("event.outcome", "failure") + .addKeyValue("event.code", "EXTERNAL_API_EVENT_PUBLISH_FAILED") + .addKeyValue("external_api.provider", provider.name()) + .addKeyValue("external_api.operation", operation) + .setCause(eventFailure) + .log("Failed to publish external API completion event."); + } + } + + private record ProviderErrorDetails(String code, String type, String message) { + private static final ProviderErrorDetails EMPTY = new ProviderErrorDetails("", "", ""); + } +} diff --git a/src/main/java/com/son/soccerStreaming/global/externalapi/ExternalApiInvocationContext.java b/src/main/java/com/son/soccerStreaming/global/externalapi/ExternalApiInvocationContext.java new file mode 100644 index 0000000..18d3f2e --- /dev/null +++ b/src/main/java/com/son/soccerStreaming/global/externalapi/ExternalApiInvocationContext.java @@ -0,0 +1,13 @@ +package com.son.soccerStreaming.global.externalapi; + +public record ExternalApiInvocationContext(Long adminUserId, Long teamId, Long articleId, Integer batchSize) { + private static final ExternalApiInvocationContext SYSTEM = new ExternalApiInvocationContext(null, null, null, null); + + public static ExternalApiInvocationContext system() { return SYSTEM; } + + public static ExternalApiInvocationContext admin(Long adminUserId, Long teamId, Long articleId, Integer batchSize) { + return new ExternalApiInvocationContext(adminUserId, teamId, articleId, batchSize); + } + + public boolean isAdminRequest() { return adminUserId != null; } +} diff --git a/src/main/java/com/son/soccerStreaming/global/externalapi/ExternalApiProvider.java b/src/main/java/com/son/soccerStreaming/global/externalapi/ExternalApiProvider.java new file mode 100644 index 0000000..36e0957 --- /dev/null +++ b/src/main/java/com/son/soccerStreaming/global/externalapi/ExternalApiProvider.java @@ -0,0 +1,18 @@ +package com.son.soccerStreaming.global.externalapi; + +public enum ExternalApiProvider { + API_FOOTBALL("api-football", "API-Football"), + SERP_API("serp-api", "SerpAPI"), + OPENAI("openai", "OpenAI"); + + private final String statusKey; + private final String displayName; + + ExternalApiProvider(String statusKey, String displayName) { + this.statusKey = statusKey; + this.displayName = displayName; + } + + public String statusKey() { return statusKey; } + public String displayName() { return displayName; } +} diff --git a/src/main/java/com/son/soccerStreaming/news/client/OpenAiTitleTranslationClient.java b/src/main/java/com/son/soccerStreaming/news/client/OpenAiTitleTranslationClient.java index b3270df..d7d407f 100644 --- a/src/main/java/com/son/soccerStreaming/news/client/OpenAiTitleTranslationClient.java +++ b/src/main/java/com/son/soccerStreaming/news/client/OpenAiTitleTranslationClient.java @@ -3,6 +3,11 @@ import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; import com.son.soccerStreaming.news.config.NewsProperties; +import com.son.soccerStreaming.global.externalapi.ExternalApiErrorCategory; +import com.son.soccerStreaming.global.externalapi.ExternalApiException; +import com.son.soccerStreaming.global.externalapi.ExternalApiExecutor; +import com.son.soccerStreaming.global.externalapi.ExternalApiInvocationContext; +import com.son.soccerStreaming.global.externalapi.ExternalApiProvider; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; @@ -25,24 +30,38 @@ public class OpenAiTitleTranslationClient { private final RestClient restClient; private final NewsProperties properties; private final ObjectMapper objectMapper; + private final ExternalApiExecutor externalApiExecutor; public OpenAiTitleTranslationClient( @Qualifier("openAiNewsRestClient") RestClient restClient, NewsProperties properties, - ObjectMapper objectMapper + ObjectMapper objectMapper, + ExternalApiExecutor externalApiExecutor ) { this.restClient = restClient; this.properties = properties; this.objectMapper = objectMapper; + this.externalApiExecutor = externalApiExecutor; } public Map translate(List inputs) { + return translate(inputs, ExternalApiInvocationContext.system()); + } + + public Map translate(List inputs, ExternalApiInvocationContext context) { if (inputs.isEmpty()) { return Map.of(); } + return externalApiExecutor.execute(ExternalApiProvider.OPENAI, "translateNewsTitles", context, + () -> doTranslate(inputs)); + } + + private Map doTranslate(List inputs) { String apiKey = properties.getTranslation().getApiKey(); if (!StringUtils.hasText(apiKey)) { - throw new IllegalStateException("OpenAI API key is not configured."); + throw new ExternalApiException(ExternalApiProvider.OPENAI, "translateNewsTitles", + ExternalApiErrorCategory.CONFIGURATION, null, false, null, + "OpenAI API key is not configured", null); } Map request = new LinkedHashMap<>(); diff --git a/src/main/java/com/son/soccerStreaming/news/client/SerpApiNewsClient.java b/src/main/java/com/son/soccerStreaming/news/client/SerpApiNewsClient.java index 9437de3..1627cb1 100644 --- a/src/main/java/com/son/soccerStreaming/news/client/SerpApiNewsClient.java +++ b/src/main/java/com/son/soccerStreaming/news/client/SerpApiNewsClient.java @@ -3,6 +3,11 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import com.son.soccerStreaming.news.config.NewsProperties; +import com.son.soccerStreaming.global.externalapi.ExternalApiErrorCategory; +import com.son.soccerStreaming.global.externalapi.ExternalApiException; +import com.son.soccerStreaming.global.externalapi.ExternalApiExecutor; +import com.son.soccerStreaming.global.externalapi.ExternalApiInvocationContext; +import com.son.soccerStreaming.global.externalapi.ExternalApiProvider; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; @@ -22,19 +27,33 @@ public class SerpApiNewsClient { private final RestClient restClient; private final NewsProperties properties; + private final ExternalApiExecutor externalApiExecutor; public SerpApiNewsClient( @Qualifier("serpApiRestClient") RestClient restClient, - NewsProperties properties + NewsProperties properties, + ExternalApiExecutor externalApiExecutor ) { this.restClient = restClient; this.properties = properties; + this.externalApiExecutor = externalApiExecutor; } public List searchTeamNews(String teamName) { + return searchTeamNews(teamName, ExternalApiInvocationContext.system()); + } + + public List searchTeamNews(String teamName, ExternalApiInvocationContext context) { + return externalApiExecutor.execute(ExternalApiProvider.SERP_API, "searchTeamNews", context, + () -> doSearchTeamNews(teamName)); + } + + private List doSearchTeamNews(String teamName) { String apiKey = properties.getSerpApi().getApiKey(); if (!StringUtils.hasText(apiKey)) { - throw new IllegalStateException("SerpApi API key is not configured."); + throw new ExternalApiException(ExternalApiProvider.SERP_API, "searchTeamNews", + ExternalApiErrorCategory.CONFIGURATION, null, false, null, + "SerpAPI API key is not configured", null); } SerpApiResponse response = restClient.get() diff --git a/src/main/java/com/son/soccerStreaming/news/config/NewsClientConfig.java b/src/main/java/com/son/soccerStreaming/news/config/NewsClientConfig.java index c6458cf..54c4c4e 100644 --- a/src/main/java/com/son/soccerStreaming/news/config/NewsClientConfig.java +++ b/src/main/java/com/son/soccerStreaming/news/config/NewsClientConfig.java @@ -4,6 +4,10 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.client.RestClient; +import org.springframework.http.client.JdkClientHttpRequestFactory; + +import java.net.http.HttpClient; +import java.time.Duration; @Configuration public class NewsClientConfig { @@ -13,6 +17,8 @@ public class NewsClientConfig { public RestClient serpApiRestClient(NewsProperties properties) { return RestClient.builder() .baseUrl(properties.getSerpApi().getBaseUrl()) + .requestFactory(requestFactory(properties.getSerpApi().getConnectTimeout(), + properties.getSerpApi().getRequestTimeout())) .build(); } @@ -21,6 +27,15 @@ public RestClient serpApiRestClient(NewsProperties properties) { public RestClient openAiNewsRestClient(NewsProperties properties) { return RestClient.builder() .baseUrl(properties.getTranslation().getBaseUrl()) + .requestFactory(requestFactory(properties.getTranslation().getConnectTimeout(), + properties.getTranslation().getRequestTimeout())) .build(); } + + private JdkClientHttpRequestFactory requestFactory(Duration connectTimeout, Duration requestTimeout) { + HttpClient httpClient = HttpClient.newBuilder().connectTimeout(connectTimeout).build(); + JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient); + requestFactory.setReadTimeout(requestTimeout); + return requestFactory; + } } diff --git a/src/main/java/com/son/soccerStreaming/news/config/NewsProperties.java b/src/main/java/com/son/soccerStreaming/news/config/NewsProperties.java index ab7aa87..a32ed06 100644 --- a/src/main/java/com/son/soccerStreaming/news/config/NewsProperties.java +++ b/src/main/java/com/son/soccerStreaming/news/config/NewsProperties.java @@ -7,6 +7,7 @@ import java.util.ArrayList; import java.util.List; +import java.time.Duration; @Getter @Setter @@ -25,6 +26,8 @@ public static class SerpApi { private String apiKey = ""; private int lookbackDays = 7; private int maxArticlesPerTeam = 20; + private Duration connectTimeout = Duration.ofSeconds(3); + private Duration requestTimeout = Duration.ofSeconds(15); private List searchSites = new ArrayList<>(List.of( "bbc.com/sport/football", "skysports.com/football", @@ -43,6 +46,8 @@ public static class Translation { private String apiKey = ""; private String model = "gpt-5.6-luna"; private int batchSize = 50; + private Duration connectTimeout = Duration.ofSeconds(3); + private Duration requestTimeout = Duration.ofSeconds(60); } @Getter diff --git a/src/main/java/com/son/soccerStreaming/news/controller/AdminTeamNewsController.java b/src/main/java/com/son/soccerStreaming/news/controller/AdminTeamNewsController.java index fab4ace..2c60dd0 100644 --- a/src/main/java/com/son/soccerStreaming/news/controller/AdminTeamNewsController.java +++ b/src/main/java/com/son/soccerStreaming/news/controller/AdminTeamNewsController.java @@ -3,6 +3,8 @@ import com.son.soccerStreaming.news.service.AdminTeamNewsRefreshService; import com.son.soccerStreaming.news.service.AdminNewsTranslationService; import lombok.RequiredArgsConstructor; +import com.son.soccerStreaming.auth.security.AuthUserDetails; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -17,15 +19,19 @@ public class AdminTeamNewsController { private final AdminNewsTranslationService translationService; @PostMapping("/refresh") - public AdminTeamNewsRefreshService.RefreshResult refresh(@PathVariable Long teamId) { - return refreshService.refresh(teamId); + public AdminTeamNewsRefreshService.RefreshResult refresh( + @AuthenticationPrincipal AuthUserDetails userDetails, + @PathVariable Long teamId + ) { + return refreshService.refresh(userDetails.getId(), teamId); } @PostMapping("/{articleId}/translate") public AdminNewsTranslationService.TranslationResult translate( + @AuthenticationPrincipal AuthUserDetails userDetails, @PathVariable Long teamId, @PathVariable Long articleId ) { - return translationService.translate(teamId, articleId); + return translationService.translate(userDetails.getId(), teamId, articleId); } } diff --git a/src/main/java/com/son/soccerStreaming/news/repository/TeamNewsCollectionStateRepository.java b/src/main/java/com/son/soccerStreaming/news/repository/TeamNewsCollectionStateRepository.java index 3500485..962fcc5 100644 --- a/src/main/java/com/son/soccerStreaming/news/repository/TeamNewsCollectionStateRepository.java +++ b/src/main/java/com/son/soccerStreaming/news/repository/TeamNewsCollectionStateRepository.java @@ -3,5 +3,8 @@ import com.son.soccerStreaming.news.entity.TeamNewsCollectionState; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface TeamNewsCollectionStateRepository extends JpaRepository { + Optional findByTeamTeamId(Long teamId); } diff --git a/src/main/java/com/son/soccerStreaming/news/service/AdminNewsTranslationService.java b/src/main/java/com/son/soccerStreaming/news/service/AdminNewsTranslationService.java index 5a7fad1..7317742 100644 --- a/src/main/java/com/son/soccerStreaming/news/service/AdminNewsTranslationService.java +++ b/src/main/java/com/son/soccerStreaming/news/service/AdminNewsTranslationService.java @@ -12,6 +12,7 @@ import java.util.List; import java.util.Map; +import com.son.soccerStreaming.global.externalapi.ExternalApiInvocationContext; @Slf4j @Service @@ -23,6 +24,10 @@ public class AdminNewsTranslationService { private final NewsTranslationPersistenceService persistenceService; public TranslationResult translate(Long teamId, Long articleId) { + return translate(null, teamId, articleId); + } + + public TranslationResult translate(Long adminUserId, Long teamId, Long articleId) { NewsArticle article = teamNewsArticleRepository.findByTeamIdAndArticleId(teamId, articleId) .orElseThrow(() -> new CustomException(ErrorCode.NEWS_ARTICLE_NOT_FOUND)) .getArticle(); @@ -31,9 +36,12 @@ public TranslationResult translate(Long teamId, Long articleId) { } try { - Map translations = translationClient.translate(List.of( - new OpenAiTitleTranslationClient.TranslationInput(articleId, article.getOriginalTitle()) - )); + List inputs = List.of( + new OpenAiTitleTranslationClient.TranslationInput(articleId, article.getOriginalTitle())); + Map translations = adminUserId == null + ? translationClient.translate(inputs) + : translationClient.translate(inputs, + ExternalApiInvocationContext.admin(adminUserId, teamId, articleId, 1)); String translatedTitle = translations.get(articleId); if (!StringUtils.hasText(translatedTitle) || translatedTitle.length() > 1000) { throw new IllegalStateException("OpenAI did not return a valid translation for the requested article."); diff --git a/src/main/java/com/son/soccerStreaming/news/service/AdminTeamNewsRefreshService.java b/src/main/java/com/son/soccerStreaming/news/service/AdminTeamNewsRefreshService.java index e4620cc..143140f 100644 --- a/src/main/java/com/son/soccerStreaming/news/service/AdminTeamNewsRefreshService.java +++ b/src/main/java/com/son/soccerStreaming/news/service/AdminTeamNewsRefreshService.java @@ -7,6 +7,7 @@ import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import com.son.soccerStreaming.global.externalapi.ExternalApiInvocationContext; @Service @RequiredArgsConstructor @@ -17,13 +18,21 @@ public class AdminTeamNewsRefreshService { private final Set refreshingTeamIds = ConcurrentHashMap.newKeySet(); public RefreshResult refresh(Long teamId) { + return refresh(null, teamId); + } + + public RefreshResult refresh(Long adminUserId, Long teamId) { if (!refreshingTeamIds.add(teamId)) { throw new CustomException(ErrorCode.ADMIN_SYNC_TOO_FREQUENT); } try { - int collectedArticles = collectionService.collectTeam(teamId); - NewsTitleTranslationService.TranslationRunResult translation = - translationService.translatePendingForTeam(teamId); + int collectedArticles = adminUserId == null + ? collectionService.collectTeam(teamId) + : collectionService.collectTeam(teamId, + ExternalApiInvocationContext.admin(adminUserId, teamId, null, null)); + NewsTitleTranslationService.TranslationRunResult translation = adminUserId == null + ? translationService.translatePendingForTeam(teamId) + : translationService.translatePendingForTeam(teamId, adminUserId); return new RefreshResult( collectedArticles, translation.candidates(), diff --git a/src/main/java/com/son/soccerStreaming/news/service/NewsCollectionService.java b/src/main/java/com/son/soccerStreaming/news/service/NewsCollectionService.java index 9ee29c2..a6226d5 100644 --- a/src/main/java/com/son/soccerStreaming/news/service/NewsCollectionService.java +++ b/src/main/java/com/son/soccerStreaming/news/service/NewsCollectionService.java @@ -11,6 +11,7 @@ import java.time.Instant; import java.util.List; +import com.son.soccerStreaming.global.externalapi.ExternalApiInvocationContext; @Slf4j @Service @@ -44,9 +45,13 @@ public CollectionResult collectAllTeams() { } public int collectTeam(Long teamId) { + return collectTeam(teamId, ExternalApiInvocationContext.system()); + } + + public int collectTeam(Long teamId, ExternalApiInvocationContext context) { Team team = teamRepository.findByTeamId(teamId) .orElseThrow(() -> new CustomException(ErrorCode.TEAM_NOT_FOUND)); - List articles = serpApiNewsClient.searchTeamNews(team.getName()); + List articles = serpApiNewsClient.searchTeamNews(team.getName(), context); return newsPersistenceService.saveTeamArticles(team.getTeamId(), articles, Instant.now()); } diff --git a/src/main/java/com/son/soccerStreaming/news/service/NewsPersistenceService.java b/src/main/java/com/son/soccerStreaming/news/service/NewsPersistenceService.java index 0ebf6e0..278ed81 100644 --- a/src/main/java/com/son/soccerStreaming/news/service/NewsPersistenceService.java +++ b/src/main/java/com/son/soccerStreaming/news/service/NewsPersistenceService.java @@ -69,7 +69,7 @@ public int saveTeamArticles(Long teamId, List a teamNewsArticleRepository.save(relation); savedCount++; } - TeamNewsCollectionState collectionState = collectionStateRepository.findById(teamId) + TeamNewsCollectionState collectionState = collectionStateRepository.findByTeamTeamId(teamId) .orElseGet(() -> TeamNewsCollectionState.builder() .team(team) .lastCollectedAt(seenAt) diff --git a/src/main/java/com/son/soccerStreaming/news/service/NewsTitleTranslationService.java b/src/main/java/com/son/soccerStreaming/news/service/NewsTitleTranslationService.java index a9e0fe8..7ba2ef6 100644 --- a/src/main/java/com/son/soccerStreaming/news/service/NewsTitleTranslationService.java +++ b/src/main/java/com/son/soccerStreaming/news/service/NewsTitleTranslationService.java @@ -11,6 +11,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import com.son.soccerStreaming.global.externalapi.ExternalApiInvocationContext; @Slf4j @Service @@ -24,15 +25,19 @@ public class NewsTitleTranslationService { public TranslationRunResult translatePending() { List candidates = newsArticleRepository.findTranslationCandidates(); - return translateCandidates(candidates); + return translateCandidates(candidates, null, null); } public TranslationRunResult translatePendingForTeam(Long teamId) { + return translatePendingForTeam(teamId, null); + } + + public TranslationRunResult translatePendingForTeam(Long teamId, Long adminUserId) { List candidates = newsArticleRepository.findTranslationCandidatesByTeamId(teamId); - return translateCandidates(candidates); + return translateCandidates(candidates, adminUserId, teamId); } - private TranslationRunResult translateCandidates(List candidates) { + private TranslationRunResult translateCandidates(List candidates, Long adminUserId, Long teamId) { int translated = 0; int failed = 0; int batchSize = Math.max(1, properties.getTranslation().getBatchSize()); @@ -45,12 +50,13 @@ private TranslationRunResult translateCandidates(List candidates) { List requestedIds = batch.stream().map(NewsArticle::getId).toList(); persistenceService.markAutoTranslationAttempted(requestedIds); try { - Map translations = translationClient.translate(batch.stream() - .map(article -> new OpenAiTitleTranslationClient.TranslationInput( - article.getId(), - article.getOriginalTitle() - )) - .toList()); + List inputs = batch.stream() + .map(article -> new OpenAiTitleTranslationClient.TranslationInput(article.getId(), article.getOriginalTitle())) + .toList(); + Map translations = adminUserId == null + ? translationClient.translate(inputs) + : translationClient.translate(inputs, + ExternalApiInvocationContext.admin(adminUserId, teamId, null, inputs.size())); persistenceService.applyResults(requestedIds, translations); translated += (int) requestedIds.stream() .filter(id -> translations.get(id) != null && !translations.get(id).isBlank()) diff --git a/src/main/java/com/son/soccerStreaming/news/service/TeamNewsService.java b/src/main/java/com/son/soccerStreaming/news/service/TeamNewsService.java index 039f37d..90862d9 100644 --- a/src/main/java/com/son/soccerStreaming/news/service/TeamNewsService.java +++ b/src/main/java/com/son/soccerStreaming/news/service/TeamNewsService.java @@ -26,9 +26,8 @@ public class TeamNewsService { private final TeamNewsCollectionStateRepository collectionStateRepository; public TeamNewsListResponseDto getTeamNews(Long teamId) { - if (teamRepository.findByTeamId(teamId).isEmpty()) { - throw new CustomException(ErrorCode.TEAM_NOT_FOUND); - } + teamRepository.findByTeamId(teamId) + .orElseThrow(() -> new CustomException(ErrorCode.TEAM_NOT_FOUND)); List articles = teamNewsArticleRepository .findLatestByTeamId(teamId, PageRequest.of(0, TEAM_NEWS_LIMIT)).stream() .map(relation -> { @@ -44,7 +43,7 @@ public TeamNewsListResponseDto getTeamNews(Long teamId) { }) .toList(); return new TeamNewsListResponseDto( - collectionStateRepository.findById(teamId) + collectionStateRepository.findByTeamTeamId(teamId) .map(state -> state.getLastCollectedAt()) .orElse(null), articles diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 6dbb2ca..b22aef2 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -22,6 +22,8 @@ news: api-key: ${SERPAPI_API_KEY:} lookback-days: ${NEWS_LOOKBACK_DAYS:7} max-articles-per-team: ${NEWS_MAX_ARTICLES_PER_TEAM:20} + connect-timeout: ${SERPAPI_CONNECT_TIMEOUT:3s} + request-timeout: ${SERPAPI_REQUEST_TIMEOUT:15s} search-sites: - bbc.com/sport/football - skysports.com/football @@ -34,6 +36,8 @@ news: api-key: ${OPENAI_API_KEY:} model: ${OPENAI_NEWS_TRANSLATION_MODEL:gpt-5.6-luna} batch-size: ${NEWS_TRANSLATION_BATCH_SIZE:50} + connect-timeout: ${OPENAI_CONNECT_TIMEOUT:3s} + request-timeout: ${OPENAI_REQUEST_TIMEOUT:60s} sync: enabled: ${NEWS_SYNC_ENABLED:false} cron: ${NEWS_SYNC_CRON:0 0 6 * * *} @@ -45,10 +49,8 @@ live: base-url: ${LIVE_API_FOOTBALL_BASE_URL:https://v3.football.api-sports.io} api-key: ${API_FOOTBALL_KEY:} api-host: ${API_FOOTBALL_HOST:} - retry: - max-attempts: ${LIVE_API_FOOTBALL_RETRY_MAX_ATTEMPTS:3} - initial-delay-ms: ${LIVE_API_FOOTBALL_RETRY_INITIAL_DELAY_MS:500} - multiplier: ${LIVE_API_FOOTBALL_RETRY_MULTIPLIER:2.0} + connect-timeout: ${LIVE_API_FOOTBALL_CONNECT_TIMEOUT:3s} + request-timeout: ${LIVE_API_FOOTBALL_REQUEST_TIMEOUT:15s} circuit-breaker: enabled: ${LIVE_API_FOOTBALL_CIRCUIT_BREAKER_ENABLED:true} failure-threshold: ${LIVE_API_FOOTBALL_CIRCUIT_BREAKER_FAILURE_THRESHOLD:5} @@ -59,6 +61,14 @@ live: failure-threshold: ${LIVE_SYNC_FAILURE_THRESHOLD:3} failure-cooldown-ms: ${LIVE_SYNC_FAILURE_COOLDOWN_MS:60000} +external-api: + retry: + max-attempts: ${EXTERNAL_API_RETRY_MAX_ATTEMPTS:3} + initial-delay: ${EXTERNAL_API_RETRY_INITIAL_DELAY:500ms} + max-delay: ${EXTERNAL_API_RETRY_MAX_DELAY:5s} + max-retry-after: ${EXTERNAL_API_RETRY_MAX_RETRY_AFTER:30s} + jitter-max: ${EXTERNAL_API_RETRY_JITTER_MAX:250ms} + media: public-base-url: ${MEDIA_PUBLIC_BASE_URL:} r2: diff --git a/src/test/java/com/son/soccerStreaming/admin/service/ExternalApiAuditLogServiceTest.java b/src/test/java/com/son/soccerStreaming/admin/service/ExternalApiAuditLogServiceTest.java new file mode 100644 index 0000000..ba09476 --- /dev/null +++ b/src/test/java/com/son/soccerStreaming/admin/service/ExternalApiAuditLogServiceTest.java @@ -0,0 +1,60 @@ +package com.son.soccerStreaming.admin.service; + +import com.son.soccerStreaming.admin.entity.AdminAuditLog; +import com.son.soccerStreaming.admin.entity.AdminAuditType; +import com.son.soccerStreaming.admin.repository.AdminAuditLogRepository; +import com.son.soccerStreaming.auth.entity.AppUser; +import com.son.soccerStreaming.auth.repository.AppUserRepository; +import com.son.soccerStreaming.global.externalapi.ExternalApiCallCompletedEvent; +import com.son.soccerStreaming.global.externalapi.ExternalApiErrorCategory; +import com.son.soccerStreaming.global.externalapi.ExternalApiInvocationContext; +import com.son.soccerStreaming.global.externalapi.ExternalApiProvider; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ExternalApiAuditLogServiceTest { + + @Test + void storesOneSanitizedAuditForAnAdminCall() { + AppUserRepository userRepository = mock(AppUserRepository.class); + AdminAuditLogRepository auditRepository = mock(AdminAuditLogRepository.class); + AppUser admin = AppUser.builder().id(9L).email("admin@example.com").password("x").nickname("admin").build(); + when(userRepository.findById(9L)).thenReturn(Optional.of(admin)); + ExternalApiAuditLogService service = new ExternalApiAuditLogService(userRepository, auditRepository); + + service.record(new ExternalApiCallCompletedEvent( + ExternalApiProvider.OPENAI, "translateNewsTitles", + ExternalApiInvocationContext.admin(9L, 42L, 7L, 1), false, 3, 1250L, + null, 503, ExternalApiErrorCategory.UPSTREAM_SERVER)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AdminAuditLog.class); + verify(auditRepository).save(captor.capture()); + AdminAuditLog log = captor.getValue(); + assertThat(log.getType()).isEqualTo(AdminAuditType.EXTERNAL_API_CALL); + assertThat(log.getProvider()).isEqualTo("OPENAI"); + assertThat(log.isSuccess()).isFalse(); + assertThat(log.getDetails()).contains("teamId=42", "articleId=7", "attempts=3", "httpStatus=503") + .doesNotContain("api_key", "Authorization", "Original title"); + } + + @Test + void ignoresSystemCalls() { + AppUserRepository userRepository = mock(AppUserRepository.class); + AdminAuditLogRepository auditRepository = mock(AdminAuditLogRepository.class); + ExternalApiAuditLogService service = new ExternalApiAuditLogService(userRepository, auditRepository); + + service.record(new ExternalApiCallCompletedEvent( + ExternalApiProvider.SERP_API, "searchTeamNews", ExternalApiInvocationContext.system(), + true, 1, 10L, 2, null, null)); + + verify(auditRepository, never()).save(org.mockito.ArgumentMatchers.any()); + } +} diff --git a/src/test/java/com/son/soccerStreaming/apifootball/client/ApiFootballCircuitBreakerTest.java b/src/test/java/com/son/soccerStreaming/apifootball/client/ApiFootballCircuitBreakerTest.java index dc88f96..3f7eb79 100644 --- a/src/test/java/com/son/soccerStreaming/apifootball/client/ApiFootballCircuitBreakerTest.java +++ b/src/test/java/com/son/soccerStreaming/apifootball/client/ApiFootballCircuitBreakerTest.java @@ -1,55 +1,36 @@ package com.son.soccerStreaming.apifootball.client; -import com.son.soccerStreaming.apifootball.service.ApiFootballSyncStatusService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; class ApiFootballCircuitBreakerTest { - private ApiFootballSyncStatusService syncStatusService; private ApiFootballCircuitBreaker circuitBreaker; @BeforeEach void setUp() { - syncStatusService = mock(ApiFootballSyncStatusService.class); - circuitBreaker = new ApiFootballCircuitBreaker(syncStatusService); + circuitBreaker = new ApiFootballCircuitBreaker(); ReflectionTestUtils.setField(circuitBreaker, "enabled", true); ReflectionTestUtils.setField(circuitBreaker, "failureThreshold", 2); ReflectionTestUtils.setField(circuitBreaker, "cooldownMs", 60_000L); } @Test - void recordsGlobalFailureWhenCircuitOpensAndRecoveryOnFirstSuccess() { + void opensAfterThresholdAndRecoversOnSuccess() { RuntimeException failure = new RuntimeException("API unavailable"); circuitBreaker.recordSuccess(); circuitBreaker.recordFailure("fixtures", failure); - verify(syncStatusService, never()).recordFailureByKey("api-football", "API-Football", failure); - circuitBreaker.recordFailure("standings", failure); - verify(syncStatusService).recordFailureByKey("api-football", "API-Football", failure); assertThatThrownBy(() -> circuitBreaker.beforeRequest("players")) .isInstanceOf(ApiFootballCircuitOpenException.class); circuitBreaker.recordSuccess(); circuitBreaker.recordSuccess(); - verify(syncStatusService, times(3)).recordSuccessByKey("api-football", "API-Football"); circuitBreaker.beforeRequest("players"); } - - @Test - void recordsEverySuccessfulApiResponseAsGlobalHealthActivity() { - circuitBreaker.recordSuccess(); - circuitBreaker.recordSuccess(); - - verify(syncStatusService, times(2)).recordSuccessByKey("api-football", "API-Football"); - } } diff --git a/src/test/java/com/son/soccerStreaming/apifootball/service/ApiFootballSyncStatusServiceTest.java b/src/test/java/com/son/soccerStreaming/apifootball/service/ApiFootballSyncStatusServiceTest.java new file mode 100644 index 0000000..9a43762 --- /dev/null +++ b/src/test/java/com/son/soccerStreaming/apifootball/service/ApiFootballSyncStatusServiceTest.java @@ -0,0 +1,99 @@ +package com.son.soccerStreaming.apifootball.service; + +import com.son.soccerStreaming.apifootball.entity.ApiFootballSyncStatus; +import com.son.soccerStreaming.apifootball.repository.ApiFootballSyncStatusRepository; +import com.son.soccerStreaming.global.externalapi.ExternalApiErrorCategory; +import com.son.soccerStreaming.global.externalapi.ExternalApiException; +import com.son.soccerStreaming.global.externalapi.ExternalApiProvider; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.time.Duration; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ApiFootballSyncStatusServiceTest { + private ApiFootballSyncStatusRepository repository; + private ApiFootballSyncStatusService service; + + @BeforeEach + void setUp() { + repository = mock(ApiFootballSyncStatusRepository.class); + service = new ApiFootballSyncStatusService(repository); + } + + @Test + void createsProviderSnapshotForFinalFailure() { + when(repository.findById("openai")).thenReturn(Optional.empty()); + ExternalApiException failure = new ExternalApiException( + ExternalApiProvider.OPENAI, + "translate-news-titles", + ExternalApiErrorCategory.RATE_LIMITED, + 429, + true, + Duration.ofSeconds(2), + "External API rate limit was reached", + null + ); + + service.recordProviderFailure(ExternalApiProvider.OPENAI, "translate-news-titles", 3, failure); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ApiFootballSyncStatus.class); + verify(repository).save(captor.capture()); + ApiFootballSyncStatus status = captor.getValue(); + assertThat(status.getSyncKey()).isEqualTo("openai"); + assertThat(status.getProvider()).isEqualTo("OPENAI"); + assertThat(status.getStatus()).isEqualTo("FAILED"); + assertThat(status.getFailureCount()).isEqualTo(1); + assertThat(status.getLastOperation()).isEqualTo("translate-news-titles"); + assertThat(status.getLastErrorCategory()).isEqualTo("RATE_LIMITED"); + assertThat(status.getLastHttpStatus()).isEqualTo(429); + assertThat(status.getLastAttemptCount()).isEqualTo(3); + } + + @Test + void successRecoversExistingProviderSnapshotAndKeepsLastFailureTime() { + ApiFootballSyncStatus status = ApiFootballSyncStatus.builder() + .syncKey("serp-api") + .displayName("SerpAPI") + .lastSyncedAt(java.time.LocalDateTime.now()) + .failureCount(0) + .build(); + status.recordProviderFailure("SerpAPI", "SERP_API", "search-team-news", "UPSTREAM_SERVER", + 503, 3, java.time.LocalDateTime.now(), "External API server failed"); + when(repository.findById("serp-api")).thenReturn(Optional.of(status)); + + service.recordProviderSuccess(ExternalApiProvider.SERP_API, "search-team-news", 2); + + assertThat(status.getStatus()).isEqualTo("OK"); + assertThat(status.getFailureCount()).isZero(); + assertThat(status.getLastErrorMessage()).isNull(); + assertThat(status.getLastErrorCategory()).isNull(); + assertThat(status.getLastHttpStatus()).isNull(); + assertThat(status.getLastAttemptCount()).isEqualTo(2); + assertThat(status.getLastFailureAt()).isNotNull(); + assertThat(status.getLastSuccessAt()).isNotNull(); + verify(repository).save(status); + } + + @Test + void fillsProviderWhenLegacyTaskRowIsUpdated() { + ApiFootballSyncStatus legacy = ApiFootballSyncStatus.builder() + .syncKey("teams:2025") + .displayName("Teams 2025") + .lastSyncedAt(java.time.LocalDateTime.now()) + .failureCount(0) + .build(); + when(repository.findById("teams:2025")).thenReturn(Optional.of(legacy)); + + service.recordSuccess("teams", "Teams", 2025); + + assertThat(legacy.getProvider()).isEqualTo("API_FOOTBALL"); + verify(repository).save(legacy); + } +} diff --git a/src/test/java/com/son/soccerStreaming/global/externalapi/ExternalApiExecutorTest.java b/src/test/java/com/son/soccerStreaming/global/externalapi/ExternalApiExecutorTest.java new file mode 100644 index 0000000..791952b --- /dev/null +++ b/src/test/java/com/son/soccerStreaming/global/externalapi/ExternalApiExecutorTest.java @@ -0,0 +1,238 @@ +package com.son.soccerStreaming.global.externalapi; + +import tools.jackson.databind.ObjectMapper; +import com.son.soccerStreaming.apifootball.service.ApiFootballSyncStatusService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.converter.HttpMessageConversionException; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.RestClientException; + +import java.net.http.HttpConnectTimeoutException; +import java.net.http.HttpTimeoutException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +class ExternalApiExecutorTest { + private ApiFootballSyncStatusService statusService; + private ApplicationEventPublisher eventPublisher; + private ExternalApiExecutor executor; + + @BeforeEach + void setUp() { + statusService = mock(ApiFootballSyncStatusService.class); + eventPublisher = mock(ApplicationEventPublisher.class); + executor = new ExternalApiExecutor(statusService, eventPublisher, new ObjectMapper()); + ReflectionTestUtils.setField(executor, "maxAttempts", 3); + ReflectionTestUtils.setField(executor, "initialDelay", Duration.ZERO); + ReflectionTestUtils.setField(executor, "maxDelay", Duration.ZERO); + ReflectionTestUtils.setField(executor, "maxRetryAfter", Duration.ZERO); + ReflectionTestUtils.setField(executor, "jitterMax", Duration.ZERO); + } + + @Test + void retriesTransientServerErrorsAndRecordsRecovery() { + AtomicInteger calls = new AtomicInteger(); + + String result = executor.execute(ExternalApiProvider.OPENAI, "translate", () -> { + if (calls.incrementAndGet() < 3) { + throw HttpServerErrorException.create(HttpStatus.SERVICE_UNAVAILABLE, "unavailable", + HttpHeaders.EMPTY, new byte[0], StandardCharsets.UTF_8); + } + return "ok"; + }); + + assertThat(result).isEqualTo("ok"); + assertThat(calls).hasValue(3); + verify(statusService).recordProviderSuccess(ExternalApiProvider.OPENAI, "translate", 3); + verify(statusService, never()).recordProviderFailure(any(), any(), eq(3), any()); + } + + @Test + void doesNotRetryAuthenticationFailures() { + AtomicInteger calls = new AtomicInteger(); + + assertThatThrownBy(() -> executor.execute(ExternalApiProvider.SERP_API, "search", () -> { + calls.incrementAndGet(); + throw HttpClientErrorException.create(HttpStatus.UNAUTHORIZED, "unauthorized", + HttpHeaders.EMPTY, new byte[0], StandardCharsets.UTF_8); + })).isInstanceOfSatisfying(ExternalApiException.class, failure -> { + assertThat(failure.getCategory()).isEqualTo(ExternalApiErrorCategory.AUTHENTICATION); + assertThat(failure.isRetryable()).isFalse(); + }); + + assertThat(calls).hasValue(1); + verify(statusService).recordProviderFailure(eq(ExternalApiProvider.SERP_API), eq("search"), eq(1), any()); + } + + @Test + void distinguishesQuotaExhaustionFromRateLimit() { + var quota = HttpClientErrorException.create(HttpStatus.TOO_MANY_REQUESTS, "limited", HttpHeaders.EMPTY, + "{\"error\":{\"code\":\"insufficient_quota\",\"type\":\"insufficient_quota\",\"message\":\"You exceeded your current quota\"}}" + .getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8); + var rate = HttpClientErrorException.create(HttpStatus.TOO_MANY_REQUESTS, "limited", HttpHeaders.EMPTY, + "{\"error\":{\"code\":\"rate_limit_exceeded\",\"type\":\"requests\",\"message\":\"Rate limit reached\"}}" + .getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8); + + ExternalApiException quotaFailure = executor.classify(ExternalApiProvider.OPENAI, "translate", quota); + ExternalApiException rateFailure = executor.classify(ExternalApiProvider.OPENAI, "translate", rate); + + assertThat(quotaFailure.getCategory()).isEqualTo(ExternalApiErrorCategory.QUOTA_EXHAUSTED); + assertThat(quotaFailure.isRetryable()).isFalse(); + assertThat(rateFailure.getCategory()).isEqualTo(ExternalApiErrorCategory.RATE_LIMITED); + assertThat(rateFailure.isRetryable()).isTrue(); + } + + @Test + void classifiesSerpApiQuotaUsingItsStructuredErrorField() { + var quota = HttpClientErrorException.create(HttpStatus.TOO_MANY_REQUESTS, "limited", HttpHeaders.EMPTY, + "{\"error\":\"Your account has run out of searches.\"}" + .getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8); + var throughput = HttpClientErrorException.create(HttpStatus.TOO_MANY_REQUESTS, "limited", HttpHeaders.EMPTY, + "{\"error\":\"Hourly throughput limit exceeded.\"}" + .getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8); + + ExternalApiException quotaFailure = executor.classify(ExternalApiProvider.SERP_API, "search", quota); + ExternalApiException throughputFailure = executor.classify(ExternalApiProvider.SERP_API, "search", throughput); + + assertThat(quotaFailure.getCategory()).isEqualTo(ExternalApiErrorCategory.QUOTA_EXHAUSTED); + assertThat(quotaFailure.isRetryable()).isFalse(); + assertThat(throughputFailure.getCategory()).isEqualTo(ExternalApiErrorCategory.RATE_LIMITED); + assertThat(throughputFailure.isRetryable()).isTrue(); + } + + @Test + void keepsUnknownOrMalformed429RetryableWithoutGuessingQuota() { + var malformed = HttpClientErrorException.create(HttpStatus.TOO_MANY_REQUESTS, "limited", HttpHeaders.EMPTY, + "not-json".getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8); + + ExternalApiException failure = executor.classify(ExternalApiProvider.OPENAI, "translate", malformed); + + assertThat(failure.getCategory()).isEqualTo(ExternalApiErrorCategory.RATE_LIMITED); + assertThat(failure.isRetryable()).isTrue(); + } + + @Test + void doesNotRetryEarlierThanRetryAfterWhenItExceedsWaitLimit() { + ReflectionTestUtils.setField(executor, "maxRetryAfter", Duration.ofSeconds(30)); + HttpHeaders headers = new HttpHeaders(); + headers.set(HttpHeaders.RETRY_AFTER, "120"); + AtomicInteger calls = new AtomicInteger(); + + assertThatThrownBy(() -> executor.execute(ExternalApiProvider.OPENAI, "translate", () -> { + calls.incrementAndGet(); + throw HttpClientErrorException.create(HttpStatus.TOO_MANY_REQUESTS, "limited", headers, + "{\"error\":{\"code\":\"rate_limit_exceeded\"}}".getBytes(StandardCharsets.UTF_8), + StandardCharsets.UTF_8); + })).isInstanceOfSatisfying(ExternalApiException.class, failure -> { + assertThat(failure.isRetryable()).isTrue(); + assertThat(failure.getRetryAfter()).isEqualTo(Duration.ofSeconds(120)); + }); + + assertThat(calls).hasValue(1); + verify(statusService).recordProviderFailure(eq(ExternalApiProvider.OPENAI), eq("translate"), eq(1), any()); + } + + @Test + void parsesHttpDateRetryAfterAsAnAbsoluteInstant() { + HttpHeaders headers = new HttpHeaders(); + String retryAt = java.time.ZonedDateTime.now(java.time.ZoneOffset.UTC) + .plusSeconds(60) + .format(java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME); + headers.set(HttpHeaders.RETRY_AFTER, retryAt); + var response = HttpClientErrorException.create(HttpStatus.TOO_MANY_REQUESTS, "limited", headers, + "{\"error\":{\"code\":\"rate_limit_exceeded\"}}".getBytes(StandardCharsets.UTF_8), + StandardCharsets.UTF_8); + + ExternalApiException failure = executor.classify(ExternalApiProvider.OPENAI, "translate", response); + + assertThat(failure.getRetryAfter()).isBetween(Duration.ofSeconds(55), Duration.ofSeconds(60)); + } + + @Test + void classifiesPermanentHttpErrorsWithoutRetry() { + assertCategory(400, ExternalApiErrorCategory.BAD_REQUEST, false); + assertCategory(401, ExternalApiErrorCategory.AUTHENTICATION, false); + assertCategory(403, ExternalApiErrorCategory.PERMISSION, false); + assertCategory(404, ExternalApiErrorCategory.NOT_FOUND, false); + } + + @Test + void classifiesOnlyConfiguredTransientHttpErrorsAsRetryable() { + assertCategory(408, ExternalApiErrorCategory.TIMEOUT, true); + assertCategory(500, ExternalApiErrorCategory.UPSTREAM_SERVER, true); + assertCategory(502, ExternalApiErrorCategory.UPSTREAM_SERVER, true); + assertCategory(503, ExternalApiErrorCategory.UPSTREAM_SERVER, true); + assertCategory(504, ExternalApiErrorCategory.UPSTREAM_SERVER, true); + + var status499 = HttpClientErrorException.create(HttpStatusCode.valueOf(499), "timeout", + HttpHeaders.EMPTY, new byte[0], StandardCharsets.UTF_8); + ExternalApiException apiFootballFailure = executor.classify( + ExternalApiProvider.API_FOOTBALL, "fixtures", status499); + ExternalApiException openAiFailure = executor.classify(ExternalApiProvider.OPENAI, "translate", status499); + assertThat(apiFootballFailure.getCategory()).isEqualTo(ExternalApiErrorCategory.TIMEOUT); + assertThat(apiFootballFailure.isRetryable()).isTrue(); + assertThat(openAiFailure.isRetryable()).isFalse(); + } + + @Test + void distinguishesConnectionReadTimeoutAndInvalidResponse() { + ExternalApiException connectFailure = executor.classify(ExternalApiProvider.SERP_API, "search", + new RestClientException("connect", new HttpConnectTimeoutException("connect timeout"))); + ExternalApiException readFailure = executor.classify(ExternalApiProvider.OPENAI, "translate", + new RestClientException("read", new HttpTimeoutException("read timeout"))); + ExternalApiException invalidResponse = executor.classify(ExternalApiProvider.OPENAI, "translate", + new HttpMessageConversionException("invalid JSON")); + + assertThat(connectFailure.getCategory()).isEqualTo(ExternalApiErrorCategory.NETWORK); + assertThat(connectFailure.isRetryable()).isTrue(); + assertThat(readFailure.getCategory()).isEqualTo(ExternalApiErrorCategory.TIMEOUT); + assertThat(readFailure.isRetryable()).isTrue(); + assertThat(invalidResponse.getCategory()).isEqualTo(ExternalApiErrorCategory.INVALID_RESPONSE); + assertThat(invalidResponse.isRetryable()).isFalse(); + } + + @Test + void stopsRetryingWhenThreadIsInterrupted() { + ReflectionTestUtils.setField(executor, "initialDelay", Duration.ofSeconds(1)); + ReflectionTestUtils.setField(executor, "maxDelay", Duration.ofSeconds(1)); + AtomicInteger calls = new AtomicInteger(); + Thread.currentThread().interrupt(); + try { + assertThatThrownBy(() -> executor.execute(ExternalApiProvider.SERP_API, "search", () -> { + calls.incrementAndGet(); + throw HttpServerErrorException.create(HttpStatus.SERVICE_UNAVAILABLE, "unavailable", + HttpHeaders.EMPTY, new byte[0], StandardCharsets.UTF_8); + })).isInstanceOfSatisfying(ExternalApiException.class, failure -> { + assertThat(failure.isRetryable()).isFalse(); + assertThat(failure.getCategory()).isEqualTo(ExternalApiErrorCategory.NETWORK); + }); + } finally { + Thread.interrupted(); + } + assertThat(calls).hasValue(1); + } + + private void assertCategory(int status, ExternalApiErrorCategory category, boolean retryable) { + var exception = HttpClientErrorException.create(HttpStatusCode.valueOf(status), "error", + HttpHeaders.EMPTY, new byte[0], StandardCharsets.UTF_8); + ExternalApiException failure = executor.classify(ExternalApiProvider.OPENAI, "operation", exception); + assertThat(failure.getCategory()).isEqualTo(category); + assertThat(failure.isRetryable()).isEqualTo(retryable); + } +} diff --git a/src/test/java/com/son/soccerStreaming/news/client/OpenAiTitleTranslationClientTest.java b/src/test/java/com/son/soccerStreaming/news/client/OpenAiTitleTranslationClientTest.java index c85915b..dbd38b5 100644 --- a/src/test/java/com/son/soccerStreaming/news/client/OpenAiTitleTranslationClientTest.java +++ b/src/test/java/com/son/soccerStreaming/news/client/OpenAiTitleTranslationClientTest.java @@ -6,10 +6,14 @@ import org.springframework.test.web.client.MockRestServiceServer; import org.springframework.web.client.RestClient; import tools.jackson.databind.ObjectMapper; +import com.son.soccerStreaming.global.externalapi.ExternalApiExecutor; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; import static org.springframework.test.web.client.match.MockRestRequestMatchers.jsonPath; import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; @@ -24,8 +28,13 @@ void sendsStrictStructuredOutputAndMapsArticleIds() { properties.getTranslation().setModel("test-model"); RestClient.Builder builder = RestClient.builder().baseUrl("https://openai.test"); MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build(); + ExternalApiExecutor executor = mock(ExternalApiExecutor.class); + when(executor.execute(any(), any(), any(), any())).thenAnswer(invocation -> { + java.util.function.Supplier supplier = invocation.getArgument(3); + return supplier.get(); + }); OpenAiTitleTranslationClient client = new OpenAiTitleTranslationClient( - builder.build(), properties, new ObjectMapper()); + builder.build(), properties, new ObjectMapper(), executor); server.expect(requestTo("https://openai.test/v1/responses")) .andExpect(header("Authorization", "Bearer openai-test-key")) diff --git a/src/test/java/com/son/soccerStreaming/news/client/SerpApiNewsClientTest.java b/src/test/java/com/son/soccerStreaming/news/client/SerpApiNewsClientTest.java index 7101294..65eaea1 100644 --- a/src/test/java/com/son/soccerStreaming/news/client/SerpApiNewsClientTest.java +++ b/src/test/java/com/son/soccerStreaming/news/client/SerpApiNewsClientTest.java @@ -6,11 +6,15 @@ import org.springframework.http.MediaType; import org.springframework.test.web.client.MockRestServiceServer; import org.springframework.web.client.RestClient; +import com.son.soccerStreaming.global.externalapi.ExternalApiExecutor; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; @@ -34,7 +38,12 @@ void setUp() { )); RestClient.Builder builder = RestClient.builder().baseUrl("https://serpapi.test"); server = MockRestServiceServer.bindTo(builder).build(); - client = new SerpApiNewsClient(builder.build(), properties); + ExternalApiExecutor executor = mock(ExternalApiExecutor.class); + when(executor.execute(any(), any(), any(), any())).thenAnswer(invocation -> { + java.util.function.Supplier supplier = invocation.getArgument(3); + return supplier.get(); + }); + client = new SerpApiNewsClient(builder.build(), properties, executor); } @Test diff --git a/src/test/java/com/son/soccerStreaming/news/service/NewsPersistenceServiceTest.java b/src/test/java/com/son/soccerStreaming/news/service/NewsPersistenceServiceTest.java index 22555d9..205167e 100644 --- a/src/test/java/com/son/soccerStreaming/news/service/NewsPersistenceServiceTest.java +++ b/src/test/java/com/son/soccerStreaming/news/service/NewsPersistenceServiceTest.java @@ -173,7 +173,7 @@ void storesArticlesInSerpApiResultOrder() { @Test void recordsCollectionTimeEvenWhenNoArticlesAreReturned() { - Team team = Team.builder().teamId(1L).name("Arsenal").build(); + Team team = Team.builder().id(99L).teamId(1L).name("Arsenal").build(); Instant collectedAt = Instant.parse("2026-07-16T01:00:00Z"); when(teamRepository.findByTeamId(1L)).thenReturn(Optional.of(team)); @@ -184,6 +184,8 @@ void recordsCollectionTimeEvenWhenNoArticlesAreReturned() { ArgumentCaptor captor = ArgumentCaptor.forClass(TeamNewsCollectionState.class); verify(collectionStateRepository).save(captor.capture()); assertThat(captor.getValue().getLastCollectedAt()).isEqualTo(collectedAt); + assertThat(captor.getValue().getTeam()).isSameAs(team); + verify(collectionStateRepository).findByTeamTeamId(1L); } private NewsPersistenceService service() { diff --git a/src/test/java/com/son/soccerStreaming/news/service/TeamNewsServiceTest.java b/src/test/java/com/son/soccerStreaming/news/service/TeamNewsServiceTest.java index 2073d7c..965ea54 100644 --- a/src/test/java/com/son/soccerStreaming/news/service/TeamNewsServiceTest.java +++ b/src/test/java/com/son/soccerStreaming/news/service/TeamNewsServiceTest.java @@ -45,7 +45,7 @@ void returnsNullableTranslationAndRequestsOnlyLatestTwenty() { TeamNewsArticle relation = TeamNewsArticle.builder().team(team).article(article).build(); when(teamRepository.findByTeamId(42L)).thenReturn(Optional.of(team)); when(repository.findLatestByTeamId(42L, PageRequest.of(0, 20))).thenReturn(List.of(relation)); - when(collectionStateRepository.findById(42L)).thenReturn(Optional.of( + when(collectionStateRepository.findByTeamTeamId(42L)).thenReturn(Optional.of( TeamNewsCollectionState.builder().team(team).lastCollectedAt(publishedAt).build() )); @@ -56,6 +56,7 @@ void returnsNullableTranslationAndRequestsOnlyLatestTwenty() { assertThat(result.articles().get(0).publishedAt()).isEqualTo(publishedAt); assertThat(result.lastCollectedAt()).isEqualTo(publishedAt); verify(repository).findLatestByTeamId(42L, PageRequest.of(0, 20)); + verify(collectionStateRepository).findByTeamTeamId(42L); } @Test