diff --git a/apps/api-service/src/services/ImageGenService.ts b/apps/api-service/src/services/ImageGenService.ts index dead1ac..2576c63 100644 --- a/apps/api-service/src/services/ImageGenService.ts +++ b/apps/api-service/src/services/ImageGenService.ts @@ -12,10 +12,13 @@ import { import { StorageFactory } from './StorageFactory'; import { TTILogger, TTILogEntry } from './TTILogger'; import { NetworkErrorDetector } from '../utils/NetworkErrorDetector'; +import { GeminiErrorDetector } from '../utils/GeminiErrorDetector'; +import { ERROR_MESSAGES } from '../utils/constants/errors'; export class ImageGenService { private ai: GoogleGenAI; private primaryModel = 'gemini-2.5-flash-image'; + private static GEMINI_TIMEOUT_MS = 90_000; // 90 seconds constructor(apiKey: string) { if (!apiKey) { @@ -205,18 +208,56 @@ export class ImageGenService { try { // Use the EXACT same config and contents objects calculated above - const response = await this.ai.models.generateContent({ - model: this.primaryModel, - config, - contents, - }); + // Wrap with timeout to prevent hanging requests + const response = await this.withTimeout( + this.ai.models.generateContent({ + model: this.primaryModel, + config, + contents, + }), + ImageGenService.GEMINI_TIMEOUT_MS, + 'Gemini image generation' + ); - // Parse response - if (!response.candidates || !response.candidates[0] || !response.candidates[0].content) { - throw new Error('No response received from Gemini AI'); + // Log response structure for debugging + GeminiErrorDetector.logResponseStructure(response as any); + + // Check promptFeedback for blocked prompts FIRST + if ((response as any).promptFeedback?.blockReason) { + const errorResult = GeminiErrorDetector.analyzeResponse(response as any); + console.error( + `[ImageGenService] Prompt blocked:`, + GeminiErrorDetector.formatForLogging(errorResult!) + ); + throw new Error(errorResult!.message); } - const content = response.candidates[0].content; + // Check if we have candidates + if (!response.candidates || !response.candidates[0]) { + const errorResult = GeminiErrorDetector.analyzeResponse(response as any); + console.error(`[ImageGenService] No candidates in response`); + throw new Error(errorResult?.message || 'No response candidates from Gemini AI'); + } + + const candidate = response.candidates[0]; + + // Check finishReason for non-STOP completions + if (candidate.finishReason && candidate.finishReason !== 'STOP') { + const errorResult = GeminiErrorDetector.analyzeResponse(response as any); + console.error( + `[ImageGenService] Non-STOP finish reason:`, + GeminiErrorDetector.formatForLogging(errorResult!) + ); + throw new Error(errorResult!.message); + } + + // Check content exists + if (!candidate.content) { + console.error(`[ImageGenService] No content in candidate`); + throw new Error('No content in Gemini AI response'); + } + + const content = candidate.content; let generatedDescription: string | undefined; let imageData: { buffer: Buffer; mimeType: string } | null = null; @@ -232,7 +273,14 @@ export class ImageGenService { } if (!imageData) { - throw new Error('No image data received from Gemini AI'); + // Log what we got instead of image + const partTypes = (content.parts || []).map((p: any) => + p.inlineData ? 'image' : p.text ? 'text' : 'other' + ); + console.error(`[ImageGenService] No image data in response. Parts: [${partTypes.join(', ')}]`); + throw new Error( + `${ERROR_MESSAGES.GEMINI_NO_IMAGE}. Response contained: ${partTypes.join(', ') || 'nothing'}` + ); } const fileExtension = mime.getExtension(imageData.mimeType) || 'png'; @@ -264,6 +312,38 @@ export class ImageGenService { geminiParams, }; } catch (error) { + // Check for rate limit (HTTP 429) + const err = error as { status?: number; message?: string }; + if (err.status === 429) { + const geminiError = GeminiErrorDetector.classifyApiError(error); + console.error( + `[ImageGenService] Rate limit:`, + GeminiErrorDetector.formatForLogging(geminiError) + ); + throw new Error(geminiError.message); + } + + // Check for timeout + if (error instanceof Error && error.message.includes('timed out')) { + console.error( + `[ImageGenService] Timeout after ${ImageGenService.GEMINI_TIMEOUT_MS}ms:`, + error.message + ); + throw new Error( + `${ERROR_MESSAGES.GEMINI_TIMEOUT} after ${ImageGenService.GEMINI_TIMEOUT_MS / 1000} seconds` + ); + } + + // Check for other API errors with status codes + if (err.status) { + const geminiError = GeminiErrorDetector.classifyApiError(error); + console.error( + `[ImageGenService] API error:`, + GeminiErrorDetector.formatForLogging(geminiError) + ); + throw new Error(geminiError.message); + } + // Enhanced error detection with network diagnostics if (error instanceof Error) { // Classify the error and check for network issues (only on failure) @@ -279,6 +359,32 @@ export class ImageGenService { } } + /** + * Wrap a promise with timeout + */ + private async withTimeout( + promise: Promise, + timeoutMs: number, + operationName: string + ): Promise { + let timeoutId: NodeJS.Timeout; + + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new Error(`${operationName} timed out after ${timeoutMs}ms`)); + }, timeoutMs); + }); + + try { + const result = await Promise.race([promise, timeoutPromise]); + clearTimeout(timeoutId!); + return result; + } catch (error) { + clearTimeout(timeoutId!); + throw error; + } + } + static validateReferenceImages(files: Express.Multer.File[]): { valid: boolean; error?: string; diff --git a/apps/api-service/src/types/api.ts b/apps/api-service/src/types/api.ts index 76478e6..2fd1606 100644 --- a/apps/api-service/src/types/api.ts +++ b/apps/api-service/src/types/api.ts @@ -99,6 +99,7 @@ export interface ImageGenerationResult { model: string; geminiParams?: GeminiParams; // Gemini SDK parameters used for generation error?: string; + errorCode?: string; // Gemini-specific error code (GEMINI_RATE_LIMIT, GEMINI_TIMEOUT, etc.) errorType?: 'generation' | 'storage'; // Distinguish between generation and storage errors generatedImageData?: GeneratedImageData; // Available when generation succeeds but storage fails } diff --git a/apps/api-service/src/utils/GeminiErrorDetector.ts b/apps/api-service/src/utils/GeminiErrorDetector.ts new file mode 100644 index 0000000..a2bae1b --- /dev/null +++ b/apps/api-service/src/utils/GeminiErrorDetector.ts @@ -0,0 +1,298 @@ +import { ERROR_CODES, ERROR_MESSAGES } from './constants/errors'; + +/** + * Result of Gemini error analysis + */ +export interface GeminiErrorResult { + code: string; + message: string; + finishReason?: string | undefined; + blockReason?: string | undefined; + safetyCategories?: string[] | undefined; + retryAfter?: number | undefined; + httpStatus?: number | undefined; + technicalDetails?: string | undefined; +} + +/** + * Safety rating from Gemini response + */ +interface SafetyRating { + category?: string; + probability?: string; +} + +/** + * Gemini response structure (partial) + */ +interface GeminiResponse { + candidates?: Array<{ + finishReason?: string; + finishMessage?: string; + content?: { + parts?: Array<{ + text?: string; + inlineData?: { data?: string; mimeType?: string }; + }>; + }; + safetyRatings?: SafetyRating[]; + }>; + promptFeedback?: { + blockReason?: string; + blockReasonMessage?: string; + safetyRatings?: SafetyRating[]; + }; + usageMetadata?: { + promptTokenCount?: number; + candidatesTokenCount?: number; + totalTokenCount?: number; + }; +} + +/** + * Detector for Gemini AI specific errors + * Provides detailed error classification for rate limits, safety blocks, timeouts, etc. + */ +export class GeminiErrorDetector { + /** + * Classify an API-level error (HTTP errors from Gemini) + */ + static classifyApiError(error: unknown): GeminiErrorResult { + const err = error as { status?: number; message?: string; details?: unknown }; + + // Check for rate limit (HTTP 429) + if (err.status === 429) { + const retryAfter = this.extractRetryAfter(error); + return { + code: ERROR_CODES.GEMINI_RATE_LIMIT, + message: retryAfter + ? `${ERROR_MESSAGES.GEMINI_RATE_LIMIT}. Retry after ${retryAfter} seconds.` + : `${ERROR_MESSAGES.GEMINI_RATE_LIMIT}. Please wait before retrying.`, + httpStatus: 429, + retryAfter, + technicalDetails: err.message, + }; + } + + // Check for authentication errors + if (err.status === 401 || err.status === 403) { + return { + code: ERROR_CODES.GEMINI_API_ERROR, + message: 'Gemini API authentication failed. Check API key.', + httpStatus: err.status, + technicalDetails: err.message, + }; + } + + // Check for server errors + if (err.status === 500 || err.status === 503) { + return { + code: ERROR_CODES.GEMINI_API_ERROR, + message: 'Gemini API service temporarily unavailable.', + httpStatus: err.status, + technicalDetails: err.message, + }; + } + + // Check for bad request + if (err.status === 400) { + return { + code: ERROR_CODES.GEMINI_API_ERROR, + message: `Gemini API invalid request: ${err.message || 'Unknown error'}`, + httpStatus: 400, + technicalDetails: err.message, + }; + } + + // Generic API error + return { + code: ERROR_CODES.GEMINI_API_ERROR, + message: err.message || ERROR_MESSAGES.GEMINI_API_ERROR, + httpStatus: err.status, + technicalDetails: err.message, + }; + } + + /** + * Analyze a Gemini response for errors (finishReason, blockReason) + * Returns null if no error detected + */ + static analyzeResponse(response: GeminiResponse): GeminiErrorResult | null { + // Check promptFeedback for blocked prompts + if (response.promptFeedback?.blockReason) { + const safetyCategories = this.extractSafetyCategories( + response.promptFeedback.safetyRatings + ); + return { + code: ERROR_CODES.GEMINI_CONTENT_BLOCKED, + message: + response.promptFeedback.blockReasonMessage || + `Prompt blocked: ${response.promptFeedback.blockReason}`, + blockReason: response.promptFeedback.blockReason, + safetyCategories, + technicalDetails: `blockReason: ${response.promptFeedback.blockReason}`, + }; + } + + // Check candidate finishReason + const candidate = response.candidates?.[0]; + if (!candidate) { + return { + code: ERROR_CODES.GEMINI_NO_IMAGE, + message: 'No response candidates from Gemini AI.', + technicalDetails: 'response.candidates is empty or undefined', + }; + } + + const finishReason = candidate.finishReason; + + // STOP is normal completion + if (!finishReason || finishReason === 'STOP') { + return null; + } + + // Handle different finishReasons + switch (finishReason) { + case 'SAFETY': + case 'IMAGE_SAFETY': { + const safetyCategories = this.extractSafetyCategories(candidate.safetyRatings); + return { + code: ERROR_CODES.GEMINI_SAFETY_BLOCK, + message: `Content blocked due to safety: ${safetyCategories.join(', ') || 'unspecified'}`, + finishReason, + safetyCategories, + technicalDetails: `finishReason: ${finishReason}, safetyRatings: ${JSON.stringify(candidate.safetyRatings)}`, + }; + } + + case 'NO_IMAGE': + return { + code: ERROR_CODES.GEMINI_NO_IMAGE, + message: 'Gemini AI could not generate an image for this prompt. Try rephrasing.', + finishReason, + technicalDetails: `finishReason: ${finishReason}`, + }; + + case 'IMAGE_PROHIBITED_CONTENT': + return { + code: ERROR_CODES.GEMINI_CONTENT_BLOCKED, + message: 'Image generation blocked due to prohibited content in prompt.', + finishReason, + technicalDetails: `finishReason: ${finishReason}`, + }; + + case 'MAX_TOKENS': + return { + code: ERROR_CODES.GEMINI_API_ERROR, + message: 'Response exceeded maximum token limit. Try a shorter prompt.', + finishReason, + technicalDetails: `finishReason: ${finishReason}`, + }; + + case 'RECITATION': + case 'IMAGE_RECITATION': + return { + code: ERROR_CODES.GEMINI_CONTENT_BLOCKED, + message: 'Response blocked due to potential copyright concerns.', + finishReason, + technicalDetails: `finishReason: ${finishReason}`, + }; + + default: + return { + code: ERROR_CODES.GEMINI_API_ERROR, + message: `Generation stopped unexpectedly: ${finishReason}`, + finishReason, + technicalDetails: `finishReason: ${finishReason}, finishMessage: ${candidate.finishMessage}`, + }; + } + } + + /** + * Check if response has image data + */ + static hasImageData(response: GeminiResponse): boolean { + const parts = response.candidates?.[0]?.content?.parts; + if (!parts) return false; + return parts.some((part) => part.inlineData?.data); + } + + /** + * Format error result for logging + */ + static formatForLogging(result: GeminiErrorResult): string { + const parts = [`[${result.code}] ${result.message}`]; + + if (result.finishReason) { + parts.push(`finishReason=${result.finishReason}`); + } + if (result.blockReason) { + parts.push(`blockReason=${result.blockReason}`); + } + if (result.httpStatus) { + parts.push(`httpStatus=${result.httpStatus}`); + } + if (result.retryAfter) { + parts.push(`retryAfter=${result.retryAfter}s`); + } + if (result.safetyCategories?.length) { + parts.push(`safety=[${result.safetyCategories.join(', ')}]`); + } + + return parts.join(' | '); + } + + /** + * Log Gemini response structure for debugging + */ + static logResponseStructure(response: GeminiResponse, prefix: string = ''): void { + const parts = response.candidates?.[0]?.content?.parts || []; + const partTypes = parts.map((p) => { + if (p.inlineData) return 'image'; + if (p.text) return 'text'; + return 'other'; + }); + + console.log(`[ImageGenService]${prefix ? ` [${prefix}]` : ''} Gemini response:`, { + hasCandidates: !!response.candidates?.length, + candidateCount: response.candidates?.length || 0, + finishReason: response.candidates?.[0]?.finishReason || null, + blockReason: response.promptFeedback?.blockReason || null, + partsCount: parts.length, + partTypes, + usageMetadata: response.usageMetadata || null, + }); + } + + /** + * Extract retry-after value from error + */ + private static extractRetryAfter(error: unknown): number | undefined { + const err = error as { headers?: { get?: (key: string) => string | null } }; + + // Try to get from headers + if (err.headers?.get) { + const retryAfter = err.headers.get('retry-after'); + if (retryAfter) { + const seconds = parseInt(retryAfter, 10); + if (!isNaN(seconds)) return seconds; + } + } + + // Default retry after for rate limits + return 60; + } + + /** + * Extract safety category names from ratings + */ + private static extractSafetyCategories(ratings?: SafetyRating[]): string[] { + if (!ratings || ratings.length === 0) return []; + + // Filter for high/medium probability ratings and extract category names + return ratings + .filter((r) => r.probability === 'HIGH' || r.probability === 'MEDIUM') + .map((r) => r.category?.replace('HARM_CATEGORY_', '') || 'UNKNOWN') + .filter((c) => c !== 'UNKNOWN'); + } +} diff --git a/apps/api-service/src/utils/constants/errors.ts b/apps/api-service/src/utils/constants/errors.ts index 132714e..81191f0 100644 --- a/apps/api-service/src/utils/constants/errors.ts +++ b/apps/api-service/src/utils/constants/errors.ts @@ -51,6 +51,14 @@ export const ERROR_MESSAGES = { INTERNAL_SERVER_ERROR: 'Internal server error', INVALID_REQUEST: 'Invalid request', OPERATION_FAILED: 'Operation failed', + + // Gemini AI Errors + GEMINI_RATE_LIMIT: 'Gemini API rate limit exceeded', + GEMINI_CONTENT_BLOCKED: 'Content blocked by Gemini safety filters', + GEMINI_TIMEOUT: 'Gemini API request timed out', + GEMINI_NO_IMAGE: 'Gemini AI could not generate image', + GEMINI_SAFETY_BLOCK: 'Content blocked due to safety concerns', + GEMINI_API_ERROR: 'Gemini API returned an error', } as const; export const ERROR_CODES = { @@ -109,6 +117,14 @@ export const ERROR_CODES = { INTERNAL_SERVER_ERROR: 'INTERNAL_SERVER_ERROR', INVALID_REQUEST: 'INVALID_REQUEST', OPERATION_FAILED: 'OPERATION_FAILED', + + // Gemini AI Errors + GEMINI_RATE_LIMIT: 'GEMINI_RATE_LIMIT', + GEMINI_CONTENT_BLOCKED: 'GEMINI_CONTENT_BLOCKED', + GEMINI_TIMEOUT: 'GEMINI_TIMEOUT', + GEMINI_NO_IMAGE: 'GEMINI_NO_IMAGE', + GEMINI_SAFETY_BLOCK: 'GEMINI_SAFETY_BLOCK', + GEMINI_API_ERROR: 'GEMINI_API_ERROR', } as const; export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];