diff --git a/src/main/java/kz/konturai/parser/service/NanoBananaImageGenerationService.java b/src/main/java/kz/konturai/parser/service/NanoBananaImageGenerationService.java index e893d61..9afd0c7 100644 --- a/src/main/java/kz/konturai/parser/service/NanoBananaImageGenerationService.java +++ b/src/main/java/kz/konturai/parser/service/NanoBananaImageGenerationService.java @@ -12,14 +12,13 @@ import org.springframework.web.reactive.function.client.ExchangeStrategies; import reactor.core.publisher.Mono; import reactor.netty.http.client.HttpClient; import org.springframework.http.client.reactive.ReactorClientHttpConnector; -import reactor.util.retry.Retry; -import reactor.util.retry.RetryBackoffSpec; import java.time.Duration; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.Semaphore; @Service public class NanoBananaImageGenerationService implements ImageGenerationService { @@ -27,6 +26,12 @@ public class NanoBananaImageGenerationService implements ImageGenerationService private static final Logger logger = LoggerFactory.getLogger(NanoBananaImageGenerationService.class); private static final String GEMINI_API_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/models/"; + // Strict concurrency control: only one image generation at a time + private final Semaphore semaphore = new Semaphore(1); + + // Pacing control: track last request timestamp + private volatile long lastRequestTimestamp = 0; + private final WebClient webClient; @Value("${google.gemini.api.key:}") @@ -35,7 +40,7 @@ public class NanoBananaImageGenerationService implements ImageGenerationService @Value("${google.gemini.image.model:gemini-2.5-flash-image}") private String model; - @Value("${google.gemini.timeoutMs:90000}") + @Value("${google.gemini.timeoutMs:120000}") private long timeoutMs; @Value("${google.gemini.retry.maxAttempts:3}") @@ -50,12 +55,12 @@ public class NanoBananaImageGenerationService implements ImageGenerationService @Value("${google.gemini.retry.multiplier:2.0}") private double retryMultiplier; - @Value("${google.gemini.rateLimit.delayMs:2000}") + @Value("${google.gemini.rateLimit.delayMs:12000}") private long rateLimitDelayMs; public NanoBananaImageGenerationService() { HttpClient httpClient = HttpClient.create() - .responseTimeout(Duration.ofMillis(90000)); + .responseTimeout(Duration.ofMillis(120000)); // Configure exchange strategies to increase buffer limit for large base64 // responses @@ -72,6 +77,14 @@ public class NanoBananaImageGenerationService implements ImageGenerationService /** * Генерирует изображение через Google Gemini Nano Banana API + * + * Logic Flow: + * 1. Acquire Semaphore (strict concurrency control) + * 2. Calculate and execute Sleep (Pacing) + * 3. Execute WebClient call with 15s+ backoff for 429 + * 4. Extract Image + * 5. Update Timestamp + * 6. Release Semaphore * * @param prompt Промпт для генерации изображения * @return Массив байтов изображения в формате PNG @@ -88,27 +101,46 @@ public class NanoBananaImageGenerationService implements ImageGenerationService return null; } + // Step 1: Acquire Semaphore (strict concurrency control) try { + semaphore.acquire(); + logger.debug("Acquired semaphore for image generation"); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.error("Interrupted while waiting for semaphore"); + return null; + } + + try { + // Step 2: Calculate and execute Sleep (Pacing) + long currentTime = System.currentTimeMillis(); + long timeSinceLastRequest = currentTime - lastRequestTimestamp; + + if (timeSinceLastRequest < rateLimitDelayMs) { + long waitTime = rateLimitDelayMs - timeSinceLastRequest; + logger.info("Pacing: Waiting {}ms before next image generation...", waitTime); + try { + Thread.sleep(waitTime); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.error("Interrupted during pacing delay"); + return null; + } + } + // Обогащаем промпт для лучшего качества генерации String enrichedPrompt = enrichPromptForGemini(prompt); - // Формируем запрос согласно формату Gemini API - Map requestBody = new HashMap<>(); - List> contents = new ArrayList<>(); - Map content = new HashMap<>(); - List> parts = new ArrayList<>(); - Map part = new HashMap<>(); - part.put("text", enrichedPrompt); - parts.add(part); - content.put("parts", parts); - contents.add(content); - requestBody.put("contents", contents); + // Формируем тело запроса + Map requestBody = buildRequestBody(enrichedPrompt); String endpoint = model + ":generateContent"; + final String operation = "generateImage"; logger.info("Requesting image generation from Nano Banana (Gemini) with model: {} and prompt: {}", model, enrichedPrompt.substring(0, Math.min(100, enrichedPrompt.length()))); + // Step 3: Execute WebClient call with enhanced retry logic for 429 Map response = webClient.post() .uri(uriBuilder -> uriBuilder .path(endpoint) @@ -120,7 +152,7 @@ public class NanoBananaImageGenerationService implements ImageGenerationService .retrieve() .bodyToMono(new ParameterizedTypeReference>() { }) - .retryWhen(createRetrySpec("generateImage")) + .retryWhen(createRetrySpecFor429(operation)) .onErrorResume(err -> { logger.error("Gemini API request failed after retries: {} - {}", err.getMessage(), err.getClass().getSimpleName()); @@ -144,21 +176,114 @@ public class NanoBananaImageGenerationService implements ImageGenerationService return null; } - // Извлекаем изображение из ответа + // Step 4: Extract Image byte[] imageBytes = extractImageFromResponse(response); if (imageBytes != null) { logger.info("Successfully generated image. Size: {} bytes", imageBytes.length); } else { logger.error("Failed to extract image from Gemini API response"); } + + // Step 5: Update Timestamp (after successful response) + lastRequestTimestamp = System.currentTimeMillis(); + return imageBytes; } catch (Exception e) { logger.error("Error generating image with Gemini: {}", e.getMessage(), e); return null; + } finally { + // Step 6: Release Semaphore + semaphore.release(); + logger.debug("Released semaphore after image generation"); } } + /** + * Создает тело запроса для Gemini API + * + * @param enrichedPrompt Обогащенный промпт + * @return Тело запроса в формате Map + */ + private Map buildRequestBody(String enrichedPrompt) { + Map requestBody = new HashMap<>(); + List> contents = new ArrayList<>(); + Map content = new HashMap<>(); + List> parts = new ArrayList<>(); + Map part = new HashMap<>(); + part.put("text", enrichedPrompt); + parts.add(part); + content.put("parts", parts); + contents.add(content); + requestBody.put("contents", contents); + return requestBody; + } + + /** + * Создает retry стратегию с улучшенной обработкой 429 ошибок. + * Для 429 ошибок использует минимум 15 секунд перед первым retry. + * + * @param operation Название операции для логирования + * @return RetryBackoffSpec с настройками для 429 и 5xx ошибок + */ + private reactor.util.retry.RetryBackoffSpec createRetrySpecFor429(String operation) { + return reactor.util.retry.Retry.backoff(maxRetryAttempts, Duration.ofSeconds(15)) + .maxBackoff(Duration.ofSeconds(90)) + .multiplier(2.0) + .filter(throwable -> { + if (throwable instanceof WebClientResponseException) { + WebClientResponseException wcre = (WebClientResponseException) throwable; + int statusCode = wcre.getStatusCode().value(); + if (statusCode == 429) { + String retryAfterHeader = wcre.getHeaders().getFirst("Retry-After"); + if (retryAfterHeader != null) { + try { + int seconds = Integer.parseInt(retryAfterHeader); + logger.warn( + "Gemini API returned 429 for operation '{}'. Retry-After: {} seconds", + operation, seconds); + } catch (NumberFormatException e) { + logger.warn( + "Gemini API returned 429 for operation '{}'. Invalid Retry-After header: {}", + operation, retryAfterHeader); + } + } else { + logger.warn( + "Gemini API returned 429 for operation '{}'. No Retry-After header. Using 15s+ backoff", + operation); + } + return true; // Retry для 429 + } + return statusCode >= 500; // Retry для 5xx + } + // Не делаем retry на других ошибках + return false; + }) + .doBeforeRetry(retrySignal -> { + long attempt = retrySignal.totalRetries() + 1; + Throwable failure = retrySignal.failure(); + + if (failure instanceof WebClientResponseException) { + WebClientResponseException wcre = (WebClientResponseException) failure; + if (wcre.getStatusCode().value() == 429) { + logger.warn( + "Retrying after rate limit (429) - attempt {}/{}", + attempt, maxRetryAttempts); + } else if (wcre.getStatusCode().value() >= 500) { + logger.warn( + "Retrying after server error ({}) - attempt {}/{}", + wcre.getStatusCode().value(), attempt, maxRetryAttempts); + } + } + }) + .onRetryExhaustedThrow((retryBackoffSpec, retrySignal) -> { + logger.error( + "Gemini request for operation '{}' exhausted all {} retry attempts. Giving up.", + operation, maxRetryAttempts); + return retrySignal.failure(); + }); + } + /** * Извлекает изображение из ответа Gemini API * Поддерживает различные форматы ответа: base64 в inlineData или URL @@ -253,92 +378,4 @@ public class NanoBananaImageGenerationService implements ImageGenerationService return baseWrapper + "Description: " + originalPrompt; } - 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(); - // Only retry on 429 (rate limit) and 5xx (server errors) - return statusCode == 429 || statusCode >= 500; - } - // Retry on network errors - return throwable instanceof java.util.concurrent.TimeoutException - || throwable instanceof java.net.ConnectException - || throwable instanceof java.io.IOException; - }) - .doBeforeRetry(retrySignal -> { - long attempt = retrySignal.totalRetries() + 1; - Throwable failure = retrySignal.failure(); - - if (failure instanceof WebClientResponseException) { - WebClientResponseException wcre = (WebClientResponseException) failure; - if (wcre.getStatusCode().value() == 429) { - String retryAfterHeader = wcre.getHeaders().getFirst("Retry-After"); - if (retryAfterHeader != null) { - try { - int seconds = Integer.parseInt(retryAfterHeader); - logger.warn( - "Gemini API returned 429 for operation '{}'. Retry-After: {} seconds (attempt {}/{})", - operation, seconds, attempt, maxRetryAttempts); - } catch (NumberFormatException e) { - logger.warn( - "Gemini API returned 429 for operation '{}'. Invalid Retry-After header: {} (attempt {}/{})", - operation, retryAfterHeader, attempt, maxRetryAttempts); - } - } else { - logger.warn( - "Gemini API returned 429 for operation '{}'. No Retry-After header. Using backoff delay (attempt {}/{})", - operation, attempt, maxRetryAttempts); - } - } else { - // Log all available error information - StringBuilder errorInfo = new StringBuilder(); - errorInfo.append("Gemini API returned error ").append(wcre.getStatusCode().value()) - .append(" (").append(wcre.getStatusCode().toString()).append(")"); - errorInfo.append(" for operation '").append(operation).append("'"); - errorInfo.append(" (attempt ").append(attempt).append("/").append(maxRetryAttempts) - .append(")"); - - // Add request URL if available - if (wcre.getRequest() != null && wcre.getRequest().getURI() != null) { - errorInfo.append("\nRequest URL: ").append(wcre.getRequest().getURI()); - } - - // Add response headers - if (wcre.getHeaders() != null && !wcre.getHeaders().isEmpty()) { - errorInfo.append("\nResponse headers: ").append(wcre.getHeaders()); - } - - // Add response body if available - try { - String responseBody = wcre.getResponseBodyAsString(); - if (responseBody != null && !responseBody.isEmpty()) { - errorInfo.append("\nResponse body: ").append(responseBody); - } - } catch (Exception e) { - errorInfo.append("\nResponse body: [unable to read: ").append(e.getMessage()) - .append("]"); - } - - // Add error message - if (wcre.getMessage() != null) { - errorInfo.append("\nError message: ").append(wcre.getMessage()); - } - - logger.warn(errorInfo.toString()); - } - } else { - logger.warn("Network error for operation '{}'. Will retry (attempt {}/{})", - operation, attempt, maxRetryAttempts); - } - }) - .onRetryExhaustedThrow((retryBackoffSpec, retrySignal) -> { - logger.error("Gemini 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 fb9e4c9..5875d41 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -98,11 +98,12 @@ image.generation.provider=nano-banana # Get API key from: https://aistudio.google.com/ google.gemini.api.key=AIzaSyAHv_R0KIk7WyZLn1tL4MglrTepsjQWT_k google.gemini.image.model=gemini-2.5-flash-image -google.gemini.timeoutMs=90000 +google.gemini.timeoutMs=120000 google.gemini.retry.maxAttempts=3 google.gemini.retry.initialDelayMs=10000 google.gemini.retry.maxDelayMs=120000 google.gemini.retry.multiplier=2.0 +google.gemini.rateLimit.delayMs=12000 # Delay between image generation requests (in milliseconds) # Helps prevent rate limiting when generating multiple images sequentially