diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7f12fc8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,17 @@ +## [Unreleased] - 2026-05-31 + +### Added + +- Added frontend regression test for image-generation timeout handling. + +### Changed + +- Frontend API helper now supports per-call timeout overrides. + +### Removed + +- None. + +### Fixed + +- Fixed frontend image generation route to handle backend timeout/request errors gracefully instead of returning 500 tracebacks. diff --git a/frontend/app/helpers.py b/frontend/app/helpers.py index 14e63d0..29d70d5 100644 --- a/frontend/app/helpers.py +++ b/frontend/app/helpers.py @@ -12,9 +12,10 @@ def _backend(path: str) -> str: def _api(method: str, path: str, *, token: str | None = None, **kwargs): headers = kwargs.pop("headers", {}) + timeout = kwargs.pop("timeout", 30) if token: headers["Authorization"] = f"Bearer {token}" - return httpx.request(method, _backend(path), headers=headers, timeout=30, **kwargs) + return httpx.request(method, _backend(path), headers=headers, timeout=timeout, **kwargs) def _model_matches_modality(model: dict, modality: str) -> bool: diff --git a/frontend/app/routes/generate.py b/frontend/app/routes/generate.py index 17e2994..d959a91 100644 --- a/frontend/app/routes/generate.py +++ b/frontend/app/routes/generate.py @@ -1,4 +1,6 @@ """Generate blueprint — text, image, video generation.""" +import httpx + from flask import ( Blueprint, flash, jsonify, redirect, render_template, request, session, url_for, ) @@ -8,6 +10,18 @@ from ..helpers import _api, _load_models, login_required generate_bp = Blueprint("generate", __name__) +def _detail_or_default(resp, default: str) -> str: + try: + payload = resp.json() + except ValueError: + return default + if isinstance(payload, dict): + detail = payload.get("detail") + if detail: + return str(detail) + return default + + @generate_bp.get("/generate") @login_required def index(): @@ -81,31 +95,36 @@ def image(): result = error = None token = session["access_token"] if request.method == "POST": - ref_file = request.files.get("reference_image") - if ref_file and ref_file.filename: - up_resp = _api( - "POST", "/images/upload", - token=token, - files={"file": (ref_file.filename, - ref_file.stream, ref_file.content_type)}, - ) - if up_resp.status_code not in (200, 201): - error = up_resp.json().get("detail", "Image upload failed.") - models = _load_models(token, "image") - return render_template("generate_image.html", result=result, error=error, models=models) + try: + ref_file = request.files.get("reference_image") + if ref_file and ref_file.filename: + up_resp = _api( + "POST", "/images/upload", + token=token, + files={"file": (ref_file.filename, + ref_file.stream, ref_file.content_type)}, + ) + if up_resp.status_code not in (200, 201): + error = _detail_or_default(up_resp, "Image upload failed.") + models = _load_models(token, "image") + return render_template("generate_image.html", result=result, error=error, models=models) - resp = _api("POST", "/generate/image", token=token, json={ - "model": request.form.get("model", "").strip(), - "prompt": request.form.get("prompt", "").strip(), - "n": int(request.form.get("n", 1)), - "size": request.form.get("size", "1024x1024"), - "aspect_ratio": request.form.get("aspect_ratio", "").strip() or None, - "image_size": request.form.get("image_size", "").strip() or None, - }) - if resp.status_code == 200: - result = resp.json() - else: - error = resp.json().get("detail", "Generation failed.") + resp = _api("POST", "/generate/image", token=token, timeout=120, json={ + "model": request.form.get("model", "").strip(), + "prompt": request.form.get("prompt", "").strip(), + "n": int(request.form.get("n", 1)), + "size": request.form.get("size", "1024x1024"), + "aspect_ratio": request.form.get("aspect_ratio", "").strip() or None, + "image_size": request.form.get("image_size", "").strip() or None, + }) + if resp.status_code == 200: + result = resp.json() + else: + error = _detail_or_default(resp, "Generation failed.") + except httpx.TimeoutException: + error = "Image generation timed out. Please try again." + except httpx.RequestError: + error = "Cannot reach generation service. Please try again." models = _load_models(token, "image") return render_template("generate_image.html", result=result, error=error, models=models) @@ -184,4 +203,4 @@ def cancel_video_job(video_id: str): """Proxy cancel request to backend.""" resp = _api( "POST", f"/generate/videos/{video_id}/cancel", token=session["access_token"]) - return jsonify(resp.json()), resp.status_code \ No newline at end of file + return jsonify(resp.json()), resp.status_code diff --git a/frontend/tests/test_frontend.py b/frontend/tests/test_frontend.py index 4a44140..048c312 100644 --- a/frontend/tests/test_frontend.py +++ b/frontend/tests/test_frontend.py @@ -3,6 +3,8 @@ import os import pytest from unittest.mock import MagicMock, patch +import httpx + os.environ.setdefault("FLASK_SECRET_KEY", "test-secret") os.environ.setdefault("BACKEND_URL", "http://backend-mock") @@ -311,6 +313,26 @@ def test_generate_image_success(client): assert b"example.com/img.png" in resp.data +def test_generate_image_timeout_shows_message(client): + _set_auth(client) + + def _request_side_effect(method, url, **kwargs): + if url.endswith("/generate/image"): + raise httpx.ReadTimeout("timed out") + if url.endswith("/auth/validate"): + return _mock_response(200, {"valid": True}) + if url.endswith("/models/"): + return _mock_response(200, [{"id": "openai/dall-e-3", "name": "DALL-E", "modality": "image"}]) + return _mock_response(200, {}) + + with patch("app.helpers.httpx.request", side_effect=_request_side_effect): + resp = client.post("/generate/image", data={ + "model": "openai/dall-e-3", "prompt": "A cat", "n": "1", "size": "1024x1024" + }) + assert resp.status_code == 200 + assert b"timed out" in resp.data + + def test_generate_video_page_renders(client): _set_auth(client) resp = client.get("/generate/video")