diff --git a/document-parser/infra/local_converter.py b/document-parser/infra/local_converter.py
index ba8875d..a126e7b 100644
--- a/document-parser/infra/local_converter.py
+++ b/document-parser/infra/local_converter.py
@@ -42,11 +42,14 @@ from domain.value_objects import (
PageElement,
)
from infra.bbox import to_topleft_list
+from infra.settings import settings
logger = logging.getLogger(__name__)
-# Thread lock — DoclingConverter is not thread-safe
+# Thread lock — DoclingConverter is not thread-safe.
+# Uses a timeout to prevent a frozen conversion from blocking all others.
_converter_lock = threading.Lock()
+_LOCK_TIMEOUT = 300 # seconds — fail fast rather than wait forever
# US Letter page dimensions (points) — fallback when page size is unknown
_DEFAULT_PAGE_WIDTH = 612.0
@@ -102,6 +105,7 @@ def _build_docling_converter(options: ConversionOptions) -> DoclingConverter:
generate_page_images=options.generate_page_images,
generate_picture_images=options.generate_picture_images,
images_scale=options.images_scale,
+ document_timeout=settings.document_timeout,
)
return DoclingConverter(
@@ -114,7 +118,11 @@ def _build_docling_converter(options: ConversionOptions) -> DoclingConverter:
def _get_default_converter() -> DoclingConverter:
global _default_converter
if _default_converter is None:
- _default_converter = _build_docling_converter(ConversionOptions())
+ try:
+ _default_converter = _build_docling_converter(ConversionOptions())
+ except Exception:
+ _default_converter = None
+ raise
return _default_converter
@@ -210,9 +218,22 @@ def _process_content_item(
def _convert_sync(file_path: str, options: ConversionOptions) -> ConversionResult:
- with _converter_lock:
+ acquired = _converter_lock.acquire(timeout=_LOCK_TIMEOUT)
+ if not acquired:
+ raise TimeoutError(
+ f"Could not acquire converter lock within {_LOCK_TIMEOUT}s — "
+ "a previous conversion may be frozen"
+ )
+ try:
conv = _select_converter(options)
- result = conv.convert(file_path)
+ kwargs: dict = {}
+ if settings.max_page_count > 0:
+ kwargs["max_num_pages"] = settings.max_page_count
+ if settings.max_file_size > 0:
+ kwargs["max_file_size"] = settings.max_file_size
+ result = conv.convert(file_path, **kwargs)
+ finally:
+ _converter_lock.release()
doc = result.document
page_count = len(doc.pages)
diff --git a/document-parser/infra/settings.py b/document-parser/infra/settings.py
index 13ed454..ebb596c 100644
--- a/document-parser/infra/settings.py
+++ b/document-parser/infra/settings.py
@@ -14,8 +14,11 @@ class Settings:
docling_serve_url: str = "http://localhost:5001"
docling_serve_api_key: str | None = None
conversion_timeout: int = 900
+ document_timeout: float = 120.0 # Docling-level per-document timeout (seconds)
max_concurrent_analyses: int = 3
- max_page_count: int = 0 # 0 = unlimited
+ default_table_mode: str = "accurate" # "accurate" or "fast"
+ max_page_count: int = 0 # 0 = unlimited (upload validation)
+ max_file_size: int = 0 # 0 = unlimited (Docling-level, bytes)
upload_dir: str = "./uploads"
db_path: str = "./data/docling_studio.db"
cors_origins: list[str] = field(
@@ -33,8 +36,11 @@ class Settings:
docling_serve_url=os.environ.get("DOCLING_SERVE_URL", "http://localhost:5001"),
docling_serve_api_key=os.environ.get("DOCLING_SERVE_API_KEY"),
conversion_timeout=int(os.environ.get("CONVERSION_TIMEOUT", "900")),
+ document_timeout=float(os.environ.get("DOCUMENT_TIMEOUT", "120.0")),
max_concurrent_analyses=int(os.environ.get("MAX_CONCURRENT_ANALYSES", "3")),
+ default_table_mode=os.environ.get("DEFAULT_TABLE_MODE", "accurate"),
max_page_count=int(os.environ.get("MAX_PAGE_COUNT", "0")),
+ max_file_size=int(os.environ.get("MAX_FILE_SIZE", "0")),
upload_dir=os.environ.get("UPLOAD_DIR", "./uploads"),
db_path=os.environ.get("DB_PATH", "./data/docling_studio.db"),
cors_origins=[o.strip() for o in cors_raw.split(",")],
diff --git a/document-parser/services/analysis_service.py b/document-parser/services/analysis_service.py
index 93c556f..f630e95 100644
--- a/document-parser/services/analysis_service.py
+++ b/document-parser/services/analysis_service.py
@@ -15,6 +15,7 @@ from typing import TYPE_CHECKING
from domain.models import AnalysisJob, AnalysisStatus
from domain.value_objects import ChunkingOptions, ChunkResult, ConversionOptions, ConversionResult
+from infra.settings import settings
if TYPE_CHECKING:
from domain.ports import DocumentChunker, DocumentConverter
@@ -151,7 +152,10 @@ class AnalysisService:
await analysis_repo.update_status(job)
logger.info("Analysis started: %s (file: %s)", job_id, filename)
- options = ConversionOptions(**(pipeline_options or {}))
+ opts_dict = pipeline_options or {}
+ if "table_mode" not in opts_dict:
+ opts_dict = {**opts_dict, "table_mode": settings.default_table_mode}
+ options = ConversionOptions(**opts_dict)
result: ConversionResult = await asyncio.wait_for(
self._converter.convert(file_path, options),
@@ -187,7 +191,33 @@ class AnalysisService:
except Exception as e:
logger.exception("Analysis failed: %s", job_id)
- await _mark_failed(job_id, str(e))
+ await _mark_failed(job_id, _classify_error(e))
+
+
+def _classify_error(exc: Exception) -> str:
+ """Return a user-friendly error message based on the exception type/content."""
+ msg = str(exc).lower()
+
+ if "invalidcxxcompiler" in msg or "no working c++ compiler" in msg:
+ return "Missing C++ compiler — set TORCHDYNAMO_DISABLE=1 to work around this"
+
+ if "out of memory" in msg or "oom" in msg:
+ return "Out of memory — try a smaller document or disable table structure analysis"
+
+ if "could not acquire converter lock" in msg:
+ return "Server busy — a previous conversion is still running. Please retry later"
+
+ if "pipeline" in msg and "failed" in msg:
+ return "Document processing failed — the document may be corrupted or unsupported"
+
+ if "timeout" in msg:
+ return "Processing took too long — try with fewer pages or simpler options"
+
+ # Fallback: truncate raw error to something reasonable
+ raw = str(exc)
+ if len(raw) > 200:
+ raw = raw[:200] + "…"
+ return raw
_background_tasks: set[asyncio.Task] = set()
diff --git a/document-parser/tests/test_pipeline_options.py b/document-parser/tests/test_pipeline_options.py
index 2ce01f6..b52fdf4 100644
--- a/document-parser/tests/test_pipeline_options.py
+++ b/document-parser/tests/test_pipeline_options.py
@@ -53,6 +53,7 @@ class TestBuildConverter:
assert opts.generate_page_images is False
assert opts.generate_picture_images is False
assert opts.images_scale == 1.0
+ assert opts.document_timeout is not None
def test_ocr_disabled(self):
conv = build_converter(ConversionOptions(do_ocr=False))
diff --git a/document-parser/tests/test_robustness.py b/document-parser/tests/test_robustness.py
new file mode 100644
index 0000000..0f48f75
--- /dev/null
+++ b/document-parser/tests/test_robustness.py
@@ -0,0 +1,369 @@
+"""Robustness tests — verify pipeline failure scenarios and protective mechanisms.
+
+Tests cover: document_timeout (C1), lock timeout (C2), convert limits (C3),
+default table_mode (H1), converter reset on failure (H5), error classification (M1).
+"""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from domain.models import AnalysisJob
+from domain.value_objects import ConversionOptions, ConversionResult, PageDetail
+from infra.settings import Settings
+from services.analysis_service import AnalysisService, _classify_error
+
+# ---------------------------------------------------------------------------
+# M1 — _classify_error: user-friendly error messages
+# ---------------------------------------------------------------------------
+
+
+class TestClassifyError:
+ """Verify _classify_error maps exceptions to user-friendly messages."""
+
+ def test_cxx_compiler_error(self):
+ exc = RuntimeError("InvalidCxxCompiler: No working C++ compiler found")
+ assert "Missing C++ compiler" in _classify_error(exc)
+
+ def test_no_working_compiler(self):
+ exc = RuntimeError("no working c++ compiler found in torch")
+ assert "Missing C++ compiler" in _classify_error(exc)
+
+ def test_out_of_memory(self):
+ exc = MemoryError("Out of memory allocating tensor")
+ assert "Out of memory" in _classify_error(exc)
+
+ def test_oom_shorthand(self):
+ exc = RuntimeError("OOM during inference on page 5")
+ assert "Out of memory" in _classify_error(exc)
+
+ def test_lock_timeout(self):
+ exc = TimeoutError("Could not acquire converter lock within 300s")
+ assert "Server busy" in _classify_error(exc)
+
+ def test_pipeline_failed(self):
+ exc = RuntimeError("Pipeline StandardPdfPipeline failed on page 3")
+ assert "Document processing failed" in _classify_error(exc)
+
+ def test_timeout_generic(self):
+ exc = TimeoutError("timeout exceeded while processing")
+ assert "Processing took too long" in _classify_error(exc)
+
+ def test_unknown_short_error(self):
+ exc = ValueError("something weird happened")
+ assert _classify_error(exc) == "something weird happened"
+
+ def test_unknown_long_error_truncated(self):
+ long_msg = "x" * 300
+ exc = ValueError(long_msg)
+ result = _classify_error(exc)
+ assert len(result) <= 201
+ assert result.endswith("…")
+
+
+# ---------------------------------------------------------------------------
+# H1 — default table_mode injection
+# ---------------------------------------------------------------------------
+
+
+class TestDefaultTableMode:
+ """Verify _run_analysis_inner injects settings.default_table_mode."""
+
+ @pytest.fixture
+ def mock_job(self):
+ return AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf")
+
+ def _make_result(self) -> ConversionResult:
+ return ConversionResult(
+ page_count=1,
+ content_markdown="# Test",
+ content_html="
Test
",
+ pages=[PageDetail(page_number=1, width=612.0, height=792.0)],
+ )
+
+ @patch("services.analysis_service.analysis_repo")
+ @patch("services.analysis_service.document_repo")
+ @pytest.mark.asyncio
+ async def test_default_table_mode_injected_when_missing(
+ self, mock_doc_repo, mock_analysis_repo, mock_job
+ ):
+ mock_analysis_repo.find_by_id = AsyncMock(return_value=mock_job)
+ mock_analysis_repo.update_status = AsyncMock()
+ mock_doc_repo.update_page_count = AsyncMock()
+
+ mock_converter = AsyncMock()
+ mock_converter.convert.return_value = self._make_result()
+
+ svc = AnalysisService(converter=mock_converter)
+ await svc._run_analysis("j1", "/tmp/test.pdf", "test.pdf", {})
+
+ opts = mock_converter.convert.call_args[0][1]
+ assert opts.table_mode == "accurate"
+
+ @patch("services.analysis_service.analysis_repo")
+ @patch("services.analysis_service.document_repo")
+ @pytest.mark.asyncio
+ async def test_default_table_mode_injected_when_none(
+ self, mock_doc_repo, mock_analysis_repo, mock_job
+ ):
+ mock_analysis_repo.find_by_id = AsyncMock(return_value=mock_job)
+ mock_analysis_repo.update_status = AsyncMock()
+ mock_doc_repo.update_page_count = AsyncMock()
+
+ mock_converter = AsyncMock()
+ mock_converter.convert.return_value = self._make_result()
+
+ svc = AnalysisService(converter=mock_converter)
+ await svc._run_analysis("j1", "/tmp/test.pdf", "test.pdf", None)
+
+ opts = mock_converter.convert.call_args[0][1]
+ assert opts.table_mode == "accurate"
+
+ @patch("services.analysis_service.analysis_repo")
+ @patch("services.analysis_service.document_repo")
+ @pytest.mark.asyncio
+ async def test_user_table_mode_preserved(self, mock_doc_repo, mock_analysis_repo, mock_job):
+ mock_analysis_repo.find_by_id = AsyncMock(return_value=mock_job)
+ mock_analysis_repo.update_status = AsyncMock()
+ mock_doc_repo.update_page_count = AsyncMock()
+
+ mock_converter = AsyncMock()
+ mock_converter.convert.return_value = self._make_result()
+
+ svc = AnalysisService(converter=mock_converter)
+ await svc._run_analysis("j1", "/tmp/test.pdf", "test.pdf", {"table_mode": "fast"})
+
+ opts = mock_converter.convert.call_args[0][1]
+ assert opts.table_mode == "fast"
+
+ @patch("services.analysis_service.settings", Settings(default_table_mode="fast"))
+ @patch("services.analysis_service.analysis_repo")
+ @patch("services.analysis_service.document_repo")
+ @pytest.mark.asyncio
+ async def test_custom_default_from_settings(self, mock_doc_repo, mock_analysis_repo, mock_job):
+ mock_analysis_repo.find_by_id = AsyncMock(return_value=mock_job)
+ mock_analysis_repo.update_status = AsyncMock()
+ mock_doc_repo.update_page_count = AsyncMock()
+
+ mock_converter = AsyncMock()
+ mock_converter.convert.return_value = self._make_result()
+
+ svc = AnalysisService(converter=mock_converter)
+ await svc._run_analysis("j1", "/tmp/test.pdf", "test.pdf", {})
+
+ opts = mock_converter.convert.call_args[0][1]
+ assert opts.table_mode == "fast"
+
+
+# ---------------------------------------------------------------------------
+# Docling-dependent tests — skip if docling is not installed
+# ---------------------------------------------------------------------------
+
+docling = pytest.importorskip("docling", reason="docling library not installed")
+
+from docling.datamodel.base_models import InputFormat # noqa: E402
+
+import infra.local_converter as lc_mod # noqa: E402
+
+build_converter = lc_mod._build_docling_converter
+convert_sync = lc_mod._convert_sync
+get_default_converter = lc_mod._get_default_converter
+
+# ---------------------------------------------------------------------------
+# C1 — document_timeout in PdfPipelineOptions
+# ---------------------------------------------------------------------------
+
+
+class TestDocumentTimeout:
+ """Verify document_timeout from settings is wired into PdfPipelineOptions."""
+
+ def _get_pipeline_options(self, converter):
+ fmt_opt = converter.format_to_options[InputFormat.PDF]
+ return fmt_opt.pipeline_options
+
+ def test_document_timeout_from_settings(self):
+ conv = build_converter(ConversionOptions())
+ opts = self._get_pipeline_options(conv)
+ assert opts.document_timeout == 120.0
+
+ @patch("infra.local_converter.settings", Settings(document_timeout=45.0))
+ def test_custom_document_timeout(self):
+ conv = build_converter(ConversionOptions())
+ opts = self._get_pipeline_options(conv)
+ assert opts.document_timeout == 45.0
+
+
+# ---------------------------------------------------------------------------
+# C2 — converter lock timeout
+# ---------------------------------------------------------------------------
+
+
+def _mock_docling_result():
+ """Create a minimal mock that satisfies _convert_sync's result processing."""
+ mock_result = MagicMock()
+ mock_result.document.pages = {}
+ mock_result.document.iterate_items.return_value = []
+ mock_result.document.export_to_markdown.return_value = ""
+ mock_result.document.export_to_html.return_value = ""
+ mock_result.document.export_to_dict.return_value = {}
+ return mock_result
+
+
+class TestConverterLockTimeout:
+ """Verify _convert_sync raises TimeoutError when lock cannot be acquired."""
+
+ @patch("infra.local_converter._converter_lock")
+ def test_lock_timeout_raises(self, mock_lock):
+ mock_lock.acquire.return_value = False
+
+ with pytest.raises(TimeoutError, match="Could not acquire converter lock"):
+ convert_sync("/tmp/test.pdf", ConversionOptions())
+
+ mock_lock.release.assert_not_called()
+
+ @patch("infra.local_converter._select_converter")
+ @patch("infra.local_converter._converter_lock")
+ def test_lock_released_on_success(self, mock_lock, mock_select):
+ mock_lock.acquire.return_value = True
+ mock_conv = MagicMock()
+ mock_conv.convert.return_value = _mock_docling_result()
+ mock_select.return_value = mock_conv
+
+ convert_sync("/tmp/test.pdf", ConversionOptions())
+
+ mock_lock.release.assert_called_once()
+
+ @patch("infra.local_converter._select_converter")
+ @patch("infra.local_converter._converter_lock")
+ def test_lock_released_on_error(self, mock_lock, mock_select):
+ mock_lock.acquire.return_value = True
+ mock_conv = MagicMock()
+ mock_conv.convert.side_effect = RuntimeError("boom")
+ mock_select.return_value = mock_conv
+
+ with pytest.raises(RuntimeError, match="boom"):
+ convert_sync("/tmp/test.pdf", ConversionOptions())
+
+ mock_lock.release.assert_called_once()
+
+
+# ---------------------------------------------------------------------------
+# C3 — max_num_pages and max_file_size forwarding
+# ---------------------------------------------------------------------------
+
+
+class TestConvertSyncLimits:
+ """Verify _convert_sync forwards limit settings to conv.convert()."""
+
+ @patch("infra.local_converter._select_converter")
+ @patch("infra.local_converter._converter_lock")
+ @patch("infra.local_converter.settings", Settings(max_page_count=10, max_file_size=0))
+ def test_max_page_count_forwarded(self, mock_lock, mock_select):
+ mock_lock.acquire.return_value = True
+ mock_conv = MagicMock()
+ mock_conv.convert.return_value = _mock_docling_result()
+ mock_select.return_value = mock_conv
+
+ convert_sync("/tmp/test.pdf", ConversionOptions())
+
+ kwargs = mock_conv.convert.call_args.kwargs
+ assert kwargs["max_num_pages"] == 10
+ assert "max_file_size" not in kwargs
+
+ @patch("infra.local_converter._select_converter")
+ @patch("infra.local_converter._converter_lock")
+ @patch("infra.local_converter.settings", Settings(max_page_count=0, max_file_size=5_000_000))
+ def test_max_file_size_forwarded(self, mock_lock, mock_select):
+ mock_lock.acquire.return_value = True
+ mock_conv = MagicMock()
+ mock_conv.convert.return_value = _mock_docling_result()
+ mock_select.return_value = mock_conv
+
+ convert_sync("/tmp/test.pdf", ConversionOptions())
+
+ kwargs = mock_conv.convert.call_args.kwargs
+ assert kwargs["max_file_size"] == 5_000_000
+ assert "max_num_pages" not in kwargs
+
+ @patch("infra.local_converter._select_converter")
+ @patch("infra.local_converter._converter_lock")
+ @patch(
+ "infra.local_converter.settings",
+ Settings(max_page_count=20, max_file_size=10_000_000),
+ )
+ def test_both_limits_forwarded(self, mock_lock, mock_select):
+ mock_lock.acquire.return_value = True
+ mock_conv = MagicMock()
+ mock_conv.convert.return_value = _mock_docling_result()
+ mock_select.return_value = mock_conv
+
+ convert_sync("/tmp/test.pdf", ConversionOptions())
+
+ kwargs = mock_conv.convert.call_args.kwargs
+ assert kwargs["max_num_pages"] == 20
+ assert kwargs["max_file_size"] == 10_000_000
+
+ @patch("infra.local_converter._select_converter")
+ @patch("infra.local_converter._converter_lock")
+ @patch("infra.local_converter.settings", Settings(max_page_count=0, max_file_size=0))
+ def test_zero_limits_not_forwarded(self, mock_lock, mock_select):
+ mock_lock.acquire.return_value = True
+ mock_conv = MagicMock()
+ mock_conv.convert.return_value = _mock_docling_result()
+ mock_select.return_value = mock_conv
+
+ convert_sync("/tmp/test.pdf", ConversionOptions())
+
+ kwargs = mock_conv.convert.call_args.kwargs
+ assert "max_num_pages" not in kwargs
+ assert "max_file_size" not in kwargs
+
+
+# ---------------------------------------------------------------------------
+# H5 — _default_converter reset on init failure
+# ---------------------------------------------------------------------------
+
+
+class TestGetDefaultConverterReset:
+ """Verify _get_default_converter resets on failure and retries on next call."""
+
+ @pytest.fixture(autouse=True)
+ def reset_default_converter(self):
+ original = lc_mod._default_converter
+ lc_mod._default_converter = None
+ yield
+ lc_mod._default_converter = original
+
+ @patch("infra.local_converter._build_docling_converter")
+ def test_init_failure_resets_to_none(self, mock_build):
+ mock_build.side_effect = RuntimeError("torch not found")
+
+ with pytest.raises(RuntimeError, match="torch not found"):
+ get_default_converter()
+
+ assert lc_mod._default_converter is None
+
+ @patch("infra.local_converter._build_docling_converter")
+ def test_retry_after_failure_succeeds(self, mock_build):
+ mock_conv = MagicMock()
+ mock_build.side_effect = [RuntimeError("torch not found"), mock_conv]
+
+ with pytest.raises(RuntimeError):
+ get_default_converter()
+
+ result = get_default_converter()
+ assert result is mock_conv
+ assert mock_build.call_count == 2
+
+ @patch("infra.local_converter._build_docling_converter")
+ def test_success_caches_converter(self, mock_build):
+ mock_conv = MagicMock()
+ mock_build.return_value = mock_conv
+
+ result1 = get_default_converter()
+ result2 = get_default_converter()
+
+ assert result1 is result2 is mock_conv
+ mock_build.assert_called_once()
diff --git a/document-parser/tests/test_settings.py b/document-parser/tests/test_settings.py
index a6f600c..1b8837c 100644
--- a/document-parser/tests/test_settings.py
+++ b/document-parser/tests/test_settings.py
@@ -14,6 +14,7 @@ class TestSettingsDefaults:
assert s.docling_serve_url == "http://localhost:5001"
assert s.docling_serve_api_key is None
assert s.conversion_timeout == 900
+ assert s.document_timeout == 120.0
assert s.max_page_count == 0
assert s.upload_dir == "./uploads"
assert s.db_path == "./data/docling_studio.db"
@@ -36,6 +37,7 @@ class TestSettingsFromEnv:
monkeypatch.setenv("DOCLING_SERVE_URL", "http://serve:9000")
monkeypatch.setenv("DOCLING_SERVE_API_KEY", "secret-key")
monkeypatch.setenv("CONVERSION_TIMEOUT", "120")
+ monkeypatch.setenv("DOCUMENT_TIMEOUT", "60.0")
monkeypatch.setenv("MAX_PAGE_COUNT", "20")
monkeypatch.setenv("UPLOAD_DIR", "/data/uploads")
monkeypatch.setenv("DB_PATH", "/data/test.db")
@@ -49,6 +51,7 @@ class TestSettingsFromEnv:
assert s.docling_serve_url == "http://serve:9000"
assert s.docling_serve_api_key == "secret-key"
assert s.conversion_timeout == 120
+ assert s.document_timeout == 60.0
assert s.max_page_count == 20
assert s.upload_dir == "/data/uploads"
assert s.db_path == "/data/test.db"
diff --git a/frontend/src/features/analysis/store.test.ts b/frontend/src/features/analysis/store.test.ts
index 6c6fb15..4806d00 100644
--- a/frontend/src/features/analysis/store.test.ts
+++ b/frontend/src/features/analysis/store.test.ts
@@ -154,17 +154,49 @@ describe('useAnalysisStore', () => {
expect(store.currentAnalysis.status).toBe('FAILED')
})
- it('polling stops on fetch error', async () => {
+ it('polling retries on transient errors and stops after MAX_POLL_RETRIES', async () => {
const job = { id: 'j1', status: 'PENDING', documentId: 'd1' }
api.createAnalysis.mockResolvedValue(job)
api.fetchAnalysis.mockRejectedValue(new Error('network'))
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
const store = useAnalysisStore()
await store.run('d1')
+ // First two errors: still polling
await vi.advanceTimersByTimeAsync(2000)
+ expect(store.running).toBe(true)
+ await vi.advanceTimersByTimeAsync(2000)
+ expect(store.running).toBe(true)
+ // Third error: stops polling
+ await vi.advanceTimersByTimeAsync(2000)
+ expect(store.running).toBe(false)
+ })
+
+ it('polling resets error count on successful fetch', async () => {
+ const job = { id: 'j1', status: 'PENDING', documentId: 'd1' }
+ api.createAnalysis.mockResolvedValue(job)
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
+
+ // Fail once, succeed, fail once — should NOT stop polling
+ api.fetchAnalysis
+ .mockRejectedValueOnce(new Error('network'))
+ .mockResolvedValueOnce({ ...job, status: 'RUNNING' })
+ .mockRejectedValueOnce(new Error('network'))
+ .mockResolvedValueOnce({ ...job, status: 'COMPLETED' })
+
+ const store = useAnalysisStore()
+ await store.run('d1')
+
+ await vi.advanceTimersByTimeAsync(2000) // error 1
+ expect(store.running).toBe(true)
+ await vi.advanceTimersByTimeAsync(2000) // success — resets counter
+ expect(store.running).toBe(true)
+ await vi.advanceTimersByTimeAsync(2000) // error 1 again
+ expect(store.running).toBe(true)
+ await vi.advanceTimersByTimeAsync(2000) // success — COMPLETED
expect(store.running).toBe(false)
})
})
diff --git a/frontend/src/features/analysis/store.ts b/frontend/src/features/analysis/store.ts
index 8891cb7..6c509a6 100644
--- a/frontend/src/features/analysis/store.ts
+++ b/frontend/src/features/analysis/store.ts
@@ -10,7 +10,8 @@ export const useAnalysisStore = defineStore('analysis', () => {
const error = ref(null)
const pollingInterval = ref | null>(null)
const pollingTimeout = ref | null>(null)
- const MAX_POLLING_DURATION = 10 * 60 * 1000 // 10 minutes
+ const MAX_POLLING_DURATION = 15 * 60 * 1000 // 15 minutes — aligned with backend timeout
+ const MAX_POLL_RETRIES = 3
const currentPages = computed(() => {
if (!currentAnalysis.value?.pagesJson) return []
@@ -87,9 +88,11 @@ export const useAnalysisStore = defineStore('analysis', () => {
function startPolling(id: string): void {
stopPolling()
+ let consecutiveErrors = 0
pollingInterval.value = setInterval(async () => {
try {
const updated = await api.fetchAnalysis(id)
+ consecutiveErrors = 0
currentAnalysis.value = updated
const idx = analyses.value.findIndex((a) => a.id === id)
if (idx !== -1) analyses.value[idx] = updated
@@ -98,10 +101,14 @@ export const useAnalysisStore = defineStore('analysis', () => {
running.value = false
}
} catch (e) {
- error.value = (e as Error).message || 'Polling error'
- console.error('Polling error', e)
- stopPolling()
- running.value = false
+ consecutiveErrors++
+ console.warn(`Polling error (${consecutiveErrors}/${MAX_POLL_RETRIES})`, e)
+ if (consecutiveErrors >= MAX_POLL_RETRIES) {
+ error.value = (e as Error).message || 'Polling error'
+ console.error('Polling abandoned after retries', e)
+ stopPolling()
+ running.value = false
+ }
}
}, 2000)
pollingTimeout.value = setTimeout(() => {