fix(product-marketing): route image generation through unified subscription validation

Backend:
- product_image_service.py: Replaced direct wavespeed_client.generate_image()
  with generate_image() from main_image_generation (unified entry point)
- This ensures subscription pre-flight validation (_validate_image_operation)
  and usage tracking (_track_image_operation_usage) are enforced
- Removed _generate_image_with_retry method and WaveSpeedClient dependency
- Animation/video/avatar services already route through ImageStudioManager - no changes needed

Frontend:
- useProductMarketing.ts: Added formatError() helper for 402/429 detection
  across all 8 API operations
- useCampaignCreator.ts: Added formatError() helper for 402/429 detection
  across all 13 API operations
- All error messages now surface subscription limits with upgrade prompts
This commit is contained in:
ajaysi
2026-05-09 17:18:16 +05:30
parent 93a1985d9f
commit 7385100017
3 changed files with 56 additions and 110 deletions

View File

@@ -13,7 +13,7 @@ from dataclasses import dataclass
from pathlib import Path
from loguru import logger
from services.wavespeed.client import WaveSpeedClient
from services.llm_providers.main_image_generation import generate_image
from utils.asset_tracker import save_asset_to_library
from services.database import SessionLocal
from fastapi import HTTPException
@@ -113,12 +113,7 @@ class ProductImageService:
def __init__(self):
"""Initialize Product Image Service."""
try:
self.wavespeed_client = WaveSpeedClient()
logger.info("[Product Image Service] Initialized")
except Exception as e:
logger.error(f"[Product Image Service] Failed to initialize WaveSpeed client: {str(e)}")
raise ProductImageServiceError(f"Failed to initialize service: {str(e)}") from e
logger.info("[Product Image Service] Initialized")
def validate_request(self, request: ProductImageRequest) -> None:
"""
@@ -260,77 +255,7 @@ class ProductImageService:
return full_prompt
def _generate_image_with_retry(
self,
model: str,
prompt: str,
width: int,
height: int,
max_retries: int = 3,
retry_delay: float = 2.0
) -> bytes:
"""
Generate image with retry logic for transient failures.
Args:
model: Model to use
prompt: Generation prompt
width: Image width
height: Image height
max_retries: Maximum number of retries
retry_delay: Delay between retries in seconds
Returns:
Generated image bytes
Raises:
ImageGenerationError: If generation fails after retries
"""
last_error = None
for attempt in range(max_retries):
try:
logger.info(f"[Product Image Service] Image generation attempt {attempt + 1}/{max_retries}")
image_bytes = self.wavespeed_client.generate_image(
model=model,
prompt=prompt,
width=width,
height=height,
enable_sync_mode=True,
timeout=120,
)
if not image_bytes:
raise ValueError("Image generation returned empty result")
if len(image_bytes) < 100: # Sanity check: image should be at least 100 bytes
raise ValueError(f"Generated image too small: {len(image_bytes)} bytes")
logger.info(f"[Product Image Service] ✅ Image generated successfully: {len(image_bytes)} bytes")
return image_bytes
except Exception as e:
last_error = e
error_msg = str(e)
logger.warning(f"[Product Image Service] Attempt {attempt + 1} failed: {error_msg}")
# Don't retry on validation errors or client errors (4xx)
if "4" in error_msg or "validation" in error_msg.lower() or "invalid" in error_msg.lower():
logger.error(f"[Product Image Service] Non-retryable error: {error_msg}")
raise ImageGenerationError(f"Image generation failed: {error_msg}") from e
# Retry on transient errors
if attempt < max_retries - 1:
logger.info(f"[Product Image Service] Retrying in {retry_delay} seconds...")
time.sleep(retry_delay)
retry_delay *= 1.5 # Exponential backoff
else:
logger.error(f"[Product Image Service] All retry attempts failed")
raise ImageGenerationError(f"Image generation failed after {max_retries} attempts: {str(last_error)}") from last_error
async def generate_product_image(
def generate_product_image(
self,
request: ProductImageRequest,
user_id: str,
@@ -374,15 +299,18 @@ class ProductImageService:
# Generate image using WaveSpeed with retry logic
try:
image_bytes = self._generate_image_with_retry(
model=model,
result = generate_image(
prompt=prompt,
width=width,
height=height,
max_retries=3,
retry_delay=2.0
options={
"provider": "wavespeed",
"model": model,
"width": width,
"height": height,
},
user_id=user_id,
)
except ImageGenerationError as e:
image_bytes = result.image_bytes
except Exception as e:
logger.error(f"[Product Image Service] Image generation failed: {str(e)}")
generation_time = time.time() - start_time
return ProductImageResult(

View File

@@ -1,6 +1,15 @@
import { useState, useCallback } from 'react';
import { aiApiClient } from '../api/client';
const formatError = (err: any, fallback: string): string => {
if (err?.response?.status === 402 || err?.response?.status === 429) {
return 'Subscription limit reached. Upgrade your plan to continue using this feature.';
}
if (err?.response?.data?.detail) return String(err.response.data.detail);
if (err?.message) return String(err.message);
return fallback;
};
export interface CampaignCreateRequest {
campaign_name: string;
goal: string;
@@ -211,7 +220,7 @@ export const useCampaignCreator = () => {
setBlueprint(response.data);
return response.data;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to create campaign blueprint';
const errorMessage = formatError(err, 'Failed to create campaign blueprint');
setError(errorMessage);
throw new Error(errorMessage);
} finally {
@@ -236,7 +245,7 @@ export const useCampaignCreator = () => {
setProposals(response.data);
return response.data;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate asset proposals';
const errorMessage = formatError(err, 'Failed to generate asset proposals');
setError(errorMessage);
throw new Error(errorMessage);
} finally {
@@ -258,7 +267,7 @@ export const useCampaignCreator = () => {
setGeneratedAsset(response.data);
return response.data;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate asset';
const errorMessage = formatError(err, 'Failed to generate asset');
setError(errorMessage);
throw new Error(errorMessage);
} finally {
@@ -276,7 +285,7 @@ export const useCampaignCreator = () => {
setBrandDNA(response.data.brand_dna);
return response.data.brand_dna;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to get brand DNA';
const errorMessage = formatError(err, 'Failed to get brand DNA');
setError(errorMessage);
throw new Error(errorMessage);
} finally {
@@ -294,7 +303,7 @@ export const useCampaignCreator = () => {
);
return response.data.brand_dna;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to get channel brand DNA';
const errorMessage = formatError(err, 'Failed to get channel brand DNA');
setError(errorMessage);
throw new Error(errorMessage);
} finally {
@@ -315,7 +324,7 @@ export const useCampaignCreator = () => {
setChannelPack(response.data);
return response.data;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to get channel pack';
const errorMessage = formatError(err, 'Failed to get channel pack');
setError(errorMessage);
throw new Error(errorMessage);
} finally {
@@ -337,7 +346,7 @@ export const useCampaignCreator = () => {
setAuditResult(response.data);
return response.data;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to audit asset';
const errorMessage = formatError(err, 'Failed to audit asset');
setError(errorMessage);
throw new Error(errorMessage);
} finally {
@@ -356,7 +365,7 @@ export const useCampaignCreator = () => {
setCampaigns(response.data.campaigns);
return response.data.campaigns;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to list campaigns';
const errorMessage = formatError(err, 'Failed to list campaigns');
setError(errorMessage);
throw new Error(errorMessage);
} finally {
@@ -370,7 +379,7 @@ export const useCampaignCreator = () => {
const response = await aiApiClient.get<CampaignBlueprint>(`/api/campaign-creator/campaigns/${campaignId}`);
return response.data;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to get campaign';
const errorMessage = formatError(err, 'Failed to get campaign');
setError(errorMessage);
throw new Error(errorMessage);
}
@@ -382,7 +391,7 @@ export const useCampaignCreator = () => {
const response = await aiApiClient.get<AssetProposalsResponse>(`/api/campaign-creator/campaigns/${campaignId}/proposals`);
return response.data;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to get proposals';
const errorMessage = formatError(err, 'Failed to get proposals');
setError(errorMessage);
throw new Error(errorMessage);
}
@@ -400,7 +409,7 @@ export const useCampaignCreator = () => {
setPreflightResult(response.data);
return response.data;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to validate campaign pre-flight';
const errorMessage = formatError(err, 'Failed to validate campaign pre-flight');
setError(errorMessage);
const errorResult: PreflightValidationResult = {
can_proceed: false,
@@ -452,7 +461,7 @@ export const useCampaignCreator = () => {
const response = await aiApiClient.get(`/api/product-marketing/personalization/defaults/${formType}`);
return response.data.defaults;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to get personalized defaults';
const errorMessage = formatError(err, 'Failed to get personalized defaults');
setError(errorMessage);
throw new Error(errorMessage);
}
@@ -468,7 +477,7 @@ export const useCampaignCreator = () => {
setRecommendations(response.data.recommendations);
return response.data.recommendations;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to get recommendations';
const errorMessage = formatError(err, 'Failed to get recommendations');
setError(errorMessage);
throw new Error(errorMessage);
} finally {

View File

@@ -1,6 +1,15 @@
import { useState, useCallback } from 'react';
import { aiApiClient } from '../api/client';
const formatError = (err: any, fallback: string): string => {
if (err?.response?.status === 402 || err?.response?.status === 429) {
return 'Subscription limit reached. Upgrade your plan to continue using this feature.';
}
if (err?.response?.data?.detail) return String(err.response.data.detail);
if (err?.message) return String(err.message);
return fallback;
};
/**
* useProductMarketing Hook
*
@@ -37,7 +46,7 @@ export const useProductMarketing = () => {
setGeneratedProductImage(response.data);
return response.data;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate product image';
const errorMessage = formatError(err, 'Failed to generate product image');
setProductImageError(errorMessage);
throw new Error(errorMessage);
} finally {
@@ -70,7 +79,7 @@ export const useProductMarketing = () => {
setGeneratedAnimation(response.data);
return response.data;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate product animation';
const errorMessage = formatError(err, 'Failed to generate product animation');
setAnimationError(errorMessage);
throw new Error(errorMessage);
} finally {
@@ -102,7 +111,7 @@ export const useProductMarketing = () => {
setGeneratedVideo(response.data);
return response.data;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate product video';
const errorMessage = formatError(err, 'Failed to generate product video');
setVideoError(errorMessage);
throw new Error(errorMessage);
} finally {
@@ -137,7 +146,7 @@ export const useProductMarketing = () => {
setGeneratedAvatar(response.data);
return response.data;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate product avatar';
const errorMessage = formatError(err, 'Failed to generate product avatar');
setAvatarError(errorMessage);
throw new Error(errorMessage);
} finally {
@@ -174,7 +183,7 @@ export const useProductMarketing = () => {
setInferredConfig(response.data.configuration);
return response.data.configuration;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to infer requirements';
const errorMessage = formatError(err, 'Failed to infer requirements');
setInferenceError(errorMessage);
throw new Error(errorMessage);
} finally {
@@ -194,8 +203,8 @@ export const useProductMarketing = () => {
setBrandDNA(response.data.brand_dna);
return response.data.brand_dna;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to get brand DNA';
setError(errorMessage);
const errorMessage = formatError(err, 'Failed to get brand DNA');
setError(errorMessage);
throw new Error(errorMessage);
} finally {
setIsLoadingBrandDNA(false);
@@ -210,8 +219,8 @@ export const useProductMarketing = () => {
setUserPreferences(response.data.preferences);
return response.data.preferences;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to get user preferences';
setError(errorMessage);
const errorMessage = formatError(err, 'Failed to get user preferences');
setError(errorMessage);
throw new Error(errorMessage);
} finally {
setIsLoadingPreferences(false);
@@ -225,7 +234,7 @@ export const useProductMarketing = () => {
const response = await aiApiClient.get(`/api/product-marketing/personalization/defaults/${formType}`);
return response.data.defaults;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to get personalized defaults';
const errorMessage = formatError(err, 'Failed to get personalized defaults');
setError(errorMessage);
throw new Error(errorMessage);
}
@@ -241,8 +250,8 @@ export const useProductMarketing = () => {
setRecommendations(response.data.recommendations);
return response.data.recommendations;
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to get recommendations';
setError(errorMessage);
const errorMessage = formatError(err, 'Failed to get recommendations');
setError(errorMessage);
throw new Error(errorMessage);
} finally {
setIsLoadingRecommendations(false);