.
This commit is contained in:
@@ -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<String, Object> requestBody = new HashMap<>();
|
||||
List<Map<String, Object>> contents = new ArrayList<>();
|
||||
Map<String, Object> content = new HashMap<>();
|
||||
List<Map<String, Object>> parts = new ArrayList<>();
|
||||
Map<String, Object> part = new HashMap<>();
|
||||
part.put("text", enrichedPrompt);
|
||||
parts.add(part);
|
||||
content.put("parts", parts);
|
||||
contents.add(content);
|
||||
requestBody.put("contents", contents);
|
||||
// Формируем тело запроса
|
||||
Map<String, Object> 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<String, Object> response = webClient.post()
|
||||
.uri(uriBuilder -> uriBuilder
|
||||
.path(endpoint)
|
||||
@@ -120,7 +152,7 @@ public class NanoBananaImageGenerationService implements ImageGenerationService
|
||||
.retrieve()
|
||||
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {
|
||||
})
|
||||
.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<String, Object> buildRequestBody(String enrichedPrompt) {
|
||||
Map<String, Object> requestBody = new HashMap<>();
|
||||
List<Map<String, Object>> contents = new ArrayList<>();
|
||||
Map<String, Object> content = new HashMap<>();
|
||||
List<Map<String, Object>> parts = new ArrayList<>();
|
||||
Map<String, Object> 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user