feat: log errors transparent

This commit is contained in:
Oleg Proskurin 2026-01-05 22:48:16 +07:00
parent 861aa325e4
commit 855ac3c111
4 changed files with 431 additions and 10 deletions

View File

@ -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;

View File

@ -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
}

View File

@ -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');
}
}

View File

@ -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];