diff --git a/nextcloud_mcp_server/vector/processor.py b/nextcloud_mcp_server/vector/processor.py index fd2cf847..4c5e6bb3 100644 --- a/nextcloud_mcp_server/vector/processor.py +++ b/nextcloud_mcp_server/vector/processor.py @@ -69,7 +69,9 @@ def _drop_reason(exc: BaseException) -> str: unknown causes fall back to ``other``. """ # An anyio task group can wrap the real cause (and nest groups when sub-tasks - # use their own groups); descend to the first concrete leaf. + # use their own groups); descend to the first concrete leaf. Best-effort: a + # group bundling several distinct failures is labelled by whichever leaf + # sorts first, not by a "mixed" bucket. while isinstance(exc, BaseExceptionGroup) and exc.exceptions: exc = exc.exceptions[0] diff --git a/tests/unit/providers/test_openai.py b/tests/unit/providers/test_openai.py index afa3cc83..00a38b0f 100644 --- a/tests/unit/providers/test_openai.py +++ b/tests/unit/providers/test_openai.py @@ -338,7 +338,7 @@ async def test_openai_close(mock_openai_client): def _req(): import httpx - return httpx.Request("POST", "http://gw/v1/embeddings") + return httpx.Request("POST", "https://gw/v1/embeddings") @pytest.mark.unit @@ -434,3 +434,26 @@ async def test_embed_batch_retries_on_connection_error(mock_openai_client, monke assert embeddings == [[0.4, 0.5, 0.6]] assert tokens == 7 assert create.await_count == 2 + + +@pytest.mark.unit +async def test_generate_retries_on_connection_error(mock_openai_client, monkeypatch): + """generate() shares the transient retry (RAG sampling survives a rollover).""" + from openai import APIConnectionError + + from nextcloud_mcp_server.providers import _retry + + monkeypatch.setattr(_retry.anyio, "sleep", AsyncMock(return_value=None)) + + choice = MagicMock() + choice.message.content = "Generated response" + mock_response = MagicMock() + mock_response.choices = [choice] + + create = AsyncMock(side_effect=[APIConnectionError(request=_req()), mock_response]) + mock_openai_client.chat.completions.create = create + provider = OpenAIProvider(api_key="test-key", generation_model="gpt-4o-mini") + + text = await provider.generate("prompt") + assert text == "Generated response" + assert create.await_count == 2 diff --git a/tests/unit/test_processor_drop_reason.py b/tests/unit/test_processor_drop_reason.py index 75074ebe..ee1e05c9 100644 --- a/tests/unit/test_processor_drop_reason.py +++ b/tests/unit/test_processor_drop_reason.py @@ -13,7 +13,7 @@ from nextcloud_mcp_server.vector import processor def _req() -> httpx.Request: - return httpx.Request("POST", "http://gw/v1/embeddings") + return httpx.Request("POST", "https://gw/v1/embeddings") @pytest.mark.unit @@ -79,3 +79,33 @@ def test_qdrant_namespace_classified(): @pytest.mark.unit def test_unknown_error_falls_back_to_other(): assert processor._drop_reason(ValueError("nope")) == "other" + + +@pytest.mark.unit +async def test_process_document_records_drop_on_exhausted_retries(mocker): + """Exhausting retries in process_document increments the drop counter with + the classified reason (and re-raises so the outer handler counts the error).""" + from nextcloud_mcp_server.vector.scanner import DocumentTask + + doc_task = DocumentTask( + user_id="alice", + doc_id="42", + doc_type="note", + operation="index", + modified_at=0, + ) + + mocker.patch.object( + processor, + "get_qdrant_client", + mocker.AsyncMock(return_value=mocker.MagicMock()), + ) + mocker.patch.object( + processor, "_index_document", side_effect=httpx.ConnectError("refused") + ) + rec = mocker.patch.object(processor, "record_ingest_dropped") + + with pytest.raises(httpx.ConnectError): + await processor.process_document(doc_task, mocker.MagicMock(), max_retries=1) + + rec.assert_called_once_with("connection")