diff --git a/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java b/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java index 33bcb6b..edc2a8d 100644 --- a/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java +++ b/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java @@ -3730,15 +3730,19 @@ public class MarketingAnalysisService { Map analysisJson = generateJsonAnalysis(request); if (analysisJson == null || analysisJson.isEmpty()) { - logger.error("generateMarketingReport: Failed to generate JSON analysis"); + logger.error( + "generateMarketingReport: Failed to generate JSON analysis - likely due to OpenAI API rate limits"); // При ошибке возвращаем базовую структуру с сообщением об ошибке report.put("analysisTypes", analysisTypeNames); report.put("detailLevel", detailLevelName); - report.put("fullAnalysis", "Не удалось сгенерировать анализ. Пожалуйста, попробуйте позже."); - report.put("summary", "Маркетинговый анализ для " + request.getProduct()); + report.put("fullAnalysis", "Не удалось сгенерировать анализ из-за превышения лимита запросов к OpenAI API. " + + + "Система автоматически повторит попытку с задержкой. Пожалуйста, попробуйте позже или уменьшите частоту запросов."); + report.put("summary", "Маркетинговый анализ для " + request.getProduct() + + " (временно недоступен из-за лимита API)"); report.put("chartsData", new HashMap<>()); - logger.warn("generateMarketingReport: Returning report with error message"); + logger.warn("generateMarketingReport: Returning report with rate limit error message"); } else { logger.info("generateMarketingReport: JSON analysis generated successfully. Keys: {}", analysisJson.keySet()); diff --git a/src/main/java/kz/konturai/parser/service/OpenAIAnalyticsService.java b/src/main/java/kz/konturai/parser/service/OpenAIAnalyticsService.java index 39f888a..9d3a999 100644 --- a/src/main/java/kz/konturai/parser/service/OpenAIAnalyticsService.java +++ b/src/main/java/kz/konturai/parser/service/OpenAIAnalyticsService.java @@ -25,6 +25,7 @@ import reactor.util.retry.Retry; import reactor.util.retry.RetryBackoffSpec; import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import jakarta.annotation.PostConstruct; @Service @@ -55,6 +56,15 @@ public class OpenAIAnalyticsService { @Value("${openai.retry.multiplier:2.0}") private double retryMultiplier; + @Value("${openai.retry.rateLimitMaxAttempts:10}") + private int rateLimitMaxAttempts; + + @Value("${openai.retry.rateLimitInitialDelayMs:5000}") + private long rateLimitInitialDelayMs; + + @Value("${openai.retry.rateLimitMaxDelayMs:300000}") + private long rateLimitMaxDelayMs; + @Value("${openai.rateLimit.maxConcurrentRequests:2}") private int maxConcurrentRequests; @@ -395,18 +405,49 @@ public class OpenAIAnalyticsService { } private RetryBackoffSpec createRetrySpec(String operation) { - return Retry.backoff(maxRetryAttempts, Duration.ofMillis(initialRetryDelayMs)) + // Store Retry-After duration for 429 errors + AtomicReference retryAfterDuration = new AtomicReference<>(null); + + // Use the higher max attempts to handle rate limit errors better + // This will apply to all retries, but rate limit errors need more attempts + int effectiveMaxAttempts = Math.max(maxRetryAttempts, rateLimitMaxAttempts); + + return Retry.backoff(effectiveMaxAttempts, Duration.ofMillis(initialRetryDelayMs)) .maxBackoff(Duration.ofMillis(maxRetryDelayMs)) .multiplier(retryMultiplier) .filter(throwable -> { if (throwable instanceof WebClientResponseException) { WebClientResponseException wcre = (WebClientResponseException) throwable; int statusCode = wcre.getStatusCode().value(); + + if (statusCode == 429) { + // Extract Retry-After header for 429 errors + String retryAfterHeader = wcre.getHeaders().getFirst("Retry-After"); + if (retryAfterHeader != null) { + try { + int seconds = Integer.parseInt(retryAfterHeader); + // Add 10% buffer to be safe, but cap at max delay + int delaySeconds = (int) Math.min(seconds * 1.1, rateLimitMaxDelayMs / 1000); + retryAfterDuration.set(Duration.ofSeconds(delaySeconds)); + logger.warn( + "OpenAI API returned 429 for operation '{}'. Retry-After: {} seconds. Will wait {}ms before retry.", + operation, seconds, retryAfterDuration.get().toMillis()); + } catch (NumberFormatException e) { + // Ignore if header is not a number + retryAfterDuration.set(null); + } + } else { + retryAfterDuration.set(null); + } + } else { + retryAfterDuration.set(null); + } + // Only retry on 429 (rate limit) and 5xx (server errors) - // Don't retry on 401, 403, etc. return statusCode == 429 || statusCode >= 500; } // Retry on network errors (timeouts, connection issues) + retryAfterDuration.set(null); return throwable instanceof java.util.concurrent.TimeoutException || throwable instanceof java.net.ConnectException || throwable instanceof java.io.IOException; @@ -414,36 +455,56 @@ public class OpenAIAnalyticsService { .doBeforeRetry(retrySignal -> { long attempt = retrySignal.totalRetries() + 1; Throwable failure = retrySignal.failure(); - - Duration retryAfter = null; - if (failure instanceof WebClientResponseException) { + + // If we have a Retry-After duration for 429 errors, sleep for that duration + // This ensures we respect the API's requested delay before Reactor applies its backoff + Duration customDelay = retryAfterDuration.get(); + if (customDelay != null && failure instanceof WebClientResponseException) { WebClientResponseException wcre = (WebClientResponseException) failure; if (wcre.getStatusCode().value() == 429) { - // Try to extract Retry-After header - String retryAfterHeader = wcre.getHeaders().getFirst("Retry-After"); - if (retryAfterHeader != null) { - try { - int seconds = Integer.parseInt(retryAfterHeader); - retryAfter = Duration.ofSeconds(seconds); - logger.warn( - "OpenAI API returned 429 for operation '{}'. Retry-After: {} seconds. Will retry in {}ms (attempt {}/{})", - operation, seconds, retryAfter.toMillis(), attempt, maxRetryAttempts); - } catch (NumberFormatException e) { - // Ignore if header is not a number - } + try { + logger.warn( + "Respecting Retry-After header: sleeping for {}ms (attempt {}/{})", + customDelay.toMillis(), attempt, rateLimitMaxAttempts); + Thread.sleep(customDelay.toMillis()); + retryAfterDuration.set(null); // Reset after use + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.warn("Interrupted while waiting for Retry-After delay"); } } } - - if (retryAfter == null) { - logger.warn("OpenAI API returned error for operation '{}'. Will retry (attempt {}/{})", + + // Log retry attempt + if (failure instanceof WebClientResponseException) { + WebClientResponseException wcre = (WebClientResponseException) failure; + int statusCode = wcre.getStatusCode().value(); + int maxAttempts = statusCode == 429 ? rateLimitMaxAttempts : maxRetryAttempts; + if (statusCode == 429 && customDelay == null) { + logger.warn("OpenAI API returned 429 for operation '{}'. Will retry with exponential backoff (attempt {}/{})", + operation, attempt, maxAttempts); + } else if (statusCode != 429) { + logger.warn("OpenAI API returned {} for operation '{}'. Will retry (attempt {}/{})", + statusCode, operation, attempt, maxAttempts); + } + } else { + logger.warn("OpenAI API network error for operation '{}'. Will retry (attempt {}/{})", operation, attempt, maxRetryAttempts); } }) .onRetryExhaustedThrow((retryBackoffSpec, retrySignal) -> { + Throwable failure = retrySignal.failure(); + int maxAttempts = maxRetryAttempts; + if (failure instanceof WebClientResponseException) { + WebClientResponseException wcre = (WebClientResponseException) failure; + if (wcre.getStatusCode().value() == 429) { + maxAttempts = rateLimitMaxAttempts; + } + } logger.error("OpenAI request for operation '{}' exhausted all {} retry attempts. Giving up.", - operation, maxRetryAttempts); + operation, maxAttempts); return retrySignal.failure(); }); } + } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 4b2d629..f10c12c 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -68,6 +68,9 @@ openai.retry.maxAttempts=3 openai.retry.initialDelayMs=2000 openai.retry.maxDelayMs=60000 openai.retry.multiplier=2.0 +openai.retry.rateLimitMaxAttempts=10 +openai.retry.rateLimitInitialDelayMs=5000 +openai.retry.rateLimitMaxDelayMs=300000 openai.rateLimit.maxConcurrentRequests=2 # Serper.dev Web Search Configuration