diff --git a/src/main/java/kz/konturai/parser/service/SerperSearchService.java b/src/main/java/kz/konturai/parser/service/SerperSearchService.java index 71bf19c..ea65e17 100644 --- a/src/main/java/kz/konturai/parser/service/SerperSearchService.java +++ b/src/main/java/kz/konturai/parser/service/SerperSearchService.java @@ -10,7 +10,10 @@ import org.springframework.http.MediaType; import org.springframework.stereotype.Service; import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClientResponseException; +import org.springframework.core.ParameterizedTypeReference; import reactor.core.publisher.Mono; +import reactor.util.retry.Retry; +import reactor.util.retry.RetryBackoffSpec; import java.net.URI; import java.time.Duration; @@ -31,6 +34,18 @@ public class SerperSearchService { @Value("${serper.timeoutMs:30000}") private long timeoutMs; + @Value("${serper.retry.maxAttempts:3}") + private int maxRetryAttempts; + + @Value("${serper.retry.initialDelayMs:1000}") + private long initialRetryDelayMs; + + @Value("${serper.retry.maxDelayMs:10000}") + private long maxRetryDelayMs; + + @Value("${serper.retry.multiplier:2.0}") + private double retryMultiplier; + public SerperSearchService() { this.webClient = WebClient.builder() .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) @@ -62,19 +77,31 @@ public class SerperSearchService { } try { - Mono call = webClient.post() + Mono> call = webClient.post() .uri(apiUrl + "/search") .header("X-API-KEY", apiKey) .bodyValue(body) .retrieve() - .bodyToMono(Map.class); + .bodyToMono(new ParameterizedTypeReference>() {}) + .retryWhen(createRetrySpec("search")) + .onErrorResume(err -> { + log.warn("Serper API request failed after retries: {} - {}", err.getMessage(), + err.getClass().getSimpleName()); + return Mono.empty(); + }); if (timeoutMs > 0) { call = call.timeout(Duration.ofMillis(timeoutMs)); } - @SuppressWarnings("unchecked") - Map response = (Map) call.block(); + Map response = call.block(); + + if (response == null) { + String msg = "Serper API request failed after all retry attempts"; + log.warn(msg); + return new SerperSearchResult(query, "ERROR", msg, List.of()); + } + List items = extractOrganicItems(response); return new SerperSearchResult(query, "OK", null, items); } catch (WebClientResponseException e) { @@ -88,7 +115,6 @@ public class SerperSearchService { } } - @SuppressWarnings("unchecked") private List extractOrganicItems(Map response) { if (response == null || response.isEmpty()) { return List.of(); @@ -134,6 +160,52 @@ public class SerperSearchService { return null; } } + + /** + * Creates a retry specification for Serper API calls. + * Retries on network errors (DNS failures, connection issues, timeouts) and 5xx server errors. + */ + private RetryBackoffSpec createRetrySpec(String operation) { + return Retry.backoff(maxRetryAttempts, Duration.ofMillis(initialRetryDelayMs)) + .maxBackoff(Duration.ofMillis(maxRetryDelayMs)) + .multiplier(retryMultiplier) + .filter(throwable -> { + if (throwable instanceof WebClientResponseException) { + WebClientResponseException wcre = (WebClientResponseException) throwable; + int statusCode = wcre.getStatusCode().value(); + // Retry on 5xx server errors and 429 rate limits + return statusCode == 429 || statusCode >= 500; + } + // Retry on network errors (DNS failures, connection issues, timeouts) + return throwable instanceof java.util.concurrent.TimeoutException + || throwable instanceof java.net.ConnectException + || throwable instanceof java.net.UnknownHostException + || throwable instanceof java.io.IOException + || throwable.getMessage() != null && ( + throwable.getMessage().contains("Failed to resolve") + || throwable.getMessage().contains("Name resolution") + || throwable.getMessage().contains("DNS")); + }) + .doBeforeRetry(retrySignal -> { + long attempt = retrySignal.totalRetries() + 1; + Throwable failure = retrySignal.failure(); + + if (failure instanceof WebClientResponseException) { + WebClientResponseException wcre = (WebClientResponseException) failure; + int statusCode = wcre.getStatusCode().value(); + log.warn("Serper API returned {} for operation '{}'. Will retry (attempt {}/{})", + statusCode, operation, attempt, maxRetryAttempts); + } else { + log.warn("Serper API network error for operation '{}' ({}). Will retry (attempt {}/{})", + operation, failure.getClass().getSimpleName(), attempt, maxRetryAttempts); + } + }) + .onRetryExhaustedThrow((retryBackoffSpec, retrySignal) -> { + log.error("Serper API request for operation '{}' exhausted all {} retry attempts. Giving up.", + operation, maxRetryAttempts); + return retrySignal.failure(); + }); + } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index f10c12c..0fa1d1b 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -77,6 +77,10 @@ openai.rateLimit.maxConcurrentRequests=2 serper.api.key=837c09c1f2836b888e461a34074b5e6436be06e4 serper.api.url=https://google.serper.dev serper.timeoutMs=30000 +serper.retry.maxAttempts=3 +serper.retry.initialDelayMs=1000 +serper.retry.maxDelayMs=10000 +serper.retry.multiplier=2.0 # OpenAI DALL-E Image Generation Configuration openai.image.model=dall-e-3