From dc82fa0003ed5ae88d3b538f5624c867817bff3b Mon Sep 17 00:00:00 2001 From: Oleksii Dolhov Date: Fri, 29 May 2026 16:11:37 +0300 Subject: [PATCH] fix(avatar): classify image-gen failures + render actionable detail (#957) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Avatar Generate dialog showed only "Failed to generate avatar" with no diagnostic info — operators couldn't tell whether the failure was a missing API key, an upstream rate limit, a safety-filter rejection, or a network timeout. Root causes: - Backend returned the raw upstream exception string as the HTTP detail. In several real failure modes (nginx 504 with HTML body, network abort) the frontend got no JSON detail at all and fell back to a hardcoded generic message. - Frontend used bare `axios` instead of the shared `@/api` client (Invariant #7), with no per-status fallback chain. Backend: - `ImageGenerationResult.error_kind` — coarse classification (`not_configured` | `invalid_input` | `safety_filter` | `rate_limited` | `upstream_error` | `timeout` | `unknown`) set on every failure path. - `_classify_exception()` maps httpx + RuntimeError exceptions to a kind. - Catch blocks now use structured logging via `extra={...}` so Vector indexes agent_name, error_kind, exception_type, etc. as fields. - `_AVATAR_ERROR_HTTP` map → kind to (HTTP status, friendly detail). `generate_avatar` and `regenerate_avatar` use the map instead of hardcoded 422 + raw exception text. Service-not-available early-exit uses the same friendly text. Frontend: - `AvatarGenerateModal.vue` switched from bare `axios` to `@/api` and bumped the per-request timeout to 180s (image gen can take >30s). - `describeAvatarError(err, verb)` falls back gracefully on 502/503/504 and no-response cases so the user gets a directional message even when the upstream strips the JSON detail. Tests: - 7 new cases in `tests/unit/test_image_generation_service.py` cover `_classify_exception` and the `error_kind` field default. Related to #957 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/backend/routers/avatar.py | 59 ++++++++++++--- .../services/image_generation_service.py | 75 ++++++++++++++++++- .../src/components/AvatarGenerateModal.vue | 30 ++++++-- tests/unit/test_image_generation_service.py | 58 ++++++++++++++ 4 files changed, 203 insertions(+), 19 deletions(-) diff --git a/src/backend/routers/avatar.py b/src/backend/routers/avatar.py index 6a5eadaa2..dae371d62 100644 --- a/src/backend/routers/avatar.py +++ b/src/backend/routers/avatar.py @@ -27,6 +27,47 @@ AVATAR_DIR = Path("/data/avatars") + +# #957: Map image-generation error_kind → (HTTP status, user-facing detail). +# Keeps the Gemini-internal error strings out of the dialog; gives the +# operator/owner an actionable message per failure mode. +_AVATAR_ERROR_HTTP = { + "not_configured": ( + 501, + "Avatar generation isn't configured on this server. Ask an admin " + "to set GEMINI_API_KEY.", + ), + "invalid_input": (400, "Avatar request was rejected by the image service."), + "safety_filter": ( + 422, + "The prompt was blocked by the image service's safety filters. " + "Try rephrasing and avoid descriptions of real people, sensitive " + "content, or trademarked characters.", + ), + "rate_limited": ( + 429, + "Image generation is rate-limited right now. Wait a minute and retry.", + ), + "upstream_error": ( + 502, + "Image generation service returned an error. This is usually transient — please retry.", + ), + "timeout": ( + 504, + "Image generation timed out. The model can be slow under load — please retry.", + ), + "unknown": ( + 422, + "Avatar generation failed. Check server logs for details.", + ), +} + + +def _avatar_http_for_result(result) -> tuple[int, str]: + """Pick the HTTP status + detail for an unsuccessful ImageGenerationResult.""" + kind = getattr(result, "error_kind", None) or "unknown" + return _AVATAR_ERROR_HTTP.get(kind, _AVATAR_ERROR_HTTP["unknown"]) + # Diverse visual styles for default avatars — deterministically assigned from agent name hash # so each agent gets a unique look even when they share the same Docker type. _DEFAULT_AVATAR_STYLES = [ @@ -374,10 +415,8 @@ async def generate_avatar( service = get_image_generation_service() if not service.available: - raise HTTPException( - status_code=501, - detail="Image generation not available: GEMINI_API_KEY not configured", - ) + status, detail = _AVATAR_ERROR_HTTP["not_configured"] + raise HTTPException(status_code=status, detail=detail) result = await service.generate_image( prompt=identity_prompt, @@ -388,7 +427,8 @@ async def generate_avatar( ) if not result.success: - raise HTTPException(status_code=422, detail=result.error) + status, detail = _avatar_http_for_result(result) + raise HTTPException(status_code=status, detail=detail) # Save optimized display avatar (.webp) and full-quality reference (.png) AVATAR_DIR.mkdir(parents=True, exist_ok=True) @@ -454,10 +494,8 @@ async def regenerate_avatar( service = get_image_generation_service() if not service.available: - raise HTTPException( - status_code=501, - detail="Image generation not available: GEMINI_API_KEY not configured", - ) + status, detail = _AVATAR_ERROR_HTTP["not_configured"] + raise HTTPException(status_code=status, detail=detail) reference_bytes = ref_path.read_bytes() result = await service.generate_variation( @@ -468,7 +506,8 @@ async def regenerate_avatar( ) if not result.success: - raise HTTPException(status_code=422, detail=result.error) + status, detail = _avatar_http_for_result(result) + raise HTTPException(status_code=status, detail=detail) # Save as optimized display avatar only (reference stays the same) avatar_path = AVATAR_DIR / f"{agent_name}.webp" diff --git a/src/backend/services/image_generation_service.py b/src/backend/services/image_generation_service.py index 6f868ab9b..6c6d4298c 100644 --- a/src/backend/services/image_generation_service.py +++ b/src/backend/services/image_generation_service.py @@ -47,6 +47,35 @@ class ImageGenerationResult: use_case: str = "general" aspect_ratio: str = "1:1" error: Optional[str] = None + # #957: coarse classification so the router can pick a meaningful HTTP + # status and the frontend can render an actionable message instead of a + # raw upstream error. Kinds: + # not_configured — GEMINI_API_KEY missing + # invalid_input — use_case / aspect_ratio rejected before API call + # safety_filter — upstream returned no image (prompt blocked) + # rate_limited — upstream HTTP 429 + # upstream_error — upstream HTTP 5xx or unparseable response + # timeout — httpx timeout / connect error + # unknown — anything else + error_kind: Optional[str] = None + + +def _classify_exception(exc: BaseException) -> str: + """Map a generation-path exception to one of the error_kind values.""" + if isinstance(exc, httpx.TimeoutException): + return "timeout" + if isinstance(exc, (httpx.ConnectError, httpx.NetworkError)): + return "upstream_error" + msg = str(exc) + if "no image data" in msg or "safety filter" in msg.lower(): + return "safety_filter" + if "API error 429" in msg: + return "rate_limited" + if "API error 5" in msg: # 500/502/503/504 + return "upstream_error" + if "API error 4" in msg: # 400/401/403 — surfaced as upstream for now + return "upstream_error" + return "unknown" class ImageGenerationService: @@ -95,6 +124,7 @@ async def generate_image( use_case=use_case, aspect_ratio=aspect_ratio, error="GEMINI_API_KEY not configured", + error_kind="not_configured", ) if use_case not in VALID_USE_CASES: @@ -104,6 +134,7 @@ async def generate_image( use_case=use_case, aspect_ratio=aspect_ratio, error=f"Invalid use_case: {use_case}. Must be one of: {VALID_USE_CASES}", + error_kind="invalid_input", ) if aspect_ratio not in VALID_ASPECT_RATIOS: @@ -113,6 +144,7 @@ async def generate_image( use_case=use_case, aspect_ratio=aspect_ratio, error=f"Invalid aspect_ratio: {aspect_ratio}. Must be one of: {VALID_ASPECT_RATIOS}", + error_kind="invalid_input", ) log_prefix = f"[IMG {agent_name or 'platform'}]" @@ -145,7 +177,19 @@ async def generate_image( aspect_ratio=aspect_ratio, ) except Exception as e: - logger.error(f"{log_prefix} Image generation failed: {e}") + kind = _classify_exception(e) + logger.error( + "image_generation_failed", + extra={ + "agent_name": agent_name or "platform", + "use_case": use_case, + "aspect_ratio": aspect_ratio, + "error_kind": kind, + "exception_type": type(e).__name__, + "error_message": str(e)[:500], + "prompt_length": len(prompt), + }, + ) return ImageGenerationResult( success=False, refined_prompt=refined if refined != prompt else None, @@ -153,6 +197,7 @@ async def generate_image( use_case=use_case, aspect_ratio=aspect_ratio, error=str(e), + error_kind=kind, ) async def refine_prompt( @@ -336,6 +381,7 @@ async def generate_variation( original_prompt=prompt, aspect_ratio=aspect_ratio, error="GEMINI_API_KEY not configured", + error_kind="not_configured", ) log_prefix = f"[IMG {agent_name or 'platform'}]" @@ -370,13 +416,24 @@ async def generate_variation( aspect_ratio=aspect_ratio, ) except Exception as e: - logger.error(f"{log_prefix} Variation generation failed: {e}") + kind = _classify_exception(e) + logger.error( + "image_variation_failed", + extra={ + "agent_name": agent_name or "platform", + "aspect_ratio": aspect_ratio, + "error_kind": kind, + "exception_type": type(e).__name__, + "error_message": str(e)[:500], + }, + ) return ImageGenerationResult( success=False, original_prompt=prompt, use_case="avatar", aspect_ratio=aspect_ratio, error=str(e), + error_kind=kind, ) async def generate_emotion_variation( @@ -408,6 +465,7 @@ async def generate_emotion_variation( original_prompt=emotion_prompt, aspect_ratio=aspect_ratio, error="GEMINI_API_KEY not configured", + error_kind="not_configured", ) log_prefix = f"[IMG {agent_name or 'platform'}]" @@ -433,13 +491,24 @@ async def generate_emotion_variation( aspect_ratio=aspect_ratio, ) except Exception as e: - logger.error(f"{log_prefix} Emotion variation generation failed: {e}") + kind = _classify_exception(e) + logger.error( + "image_emotion_variation_failed", + extra={ + "agent_name": agent_name or "platform", + "aspect_ratio": aspect_ratio, + "error_kind": kind, + "exception_type": type(e).__name__, + "error_message": str(e)[:500], + }, + ) return ImageGenerationResult( success=False, original_prompt=emotion_prompt, use_case="avatar", aspect_ratio=aspect_ratio, error=str(e), + error_kind=kind, ) async def close(self): diff --git a/src/frontend/src/components/AvatarGenerateModal.vue b/src/frontend/src/components/AvatarGenerateModal.vue index 8c662f0cb..123d821fd 100644 --- a/src/frontend/src/components/AvatarGenerateModal.vue +++ b/src/frontend/src/components/AvatarGenerateModal.vue @@ -80,7 +80,7 @@