feat: log errors transparent
This commit is contained in:
parent
861aa325e4
commit
855ac3c111
|
|
@ -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<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
operationName: string
|
||||
): Promise<T> {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
|
||||
const timeoutPromise = new Promise<never>((_, 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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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];
|
||||
|
|
|
|||
Loading…
Reference in New Issue