From e3e0c1afb62f0d8039c12d82c42dd7f2990e4c12 Mon Sep 17 00:00:00 2001 From: cang-xue <3232085039@qq.com> Date: Wed, 15 Jul 2026 10:28:18 +0800 Subject: [PATCH] feat: retry failed aidp adapter requests --- sdk/nexent/core/knowledge_base/aidp_client.py | 52 +++++--- .../core/knowledge_base/test_aidp_client.py | 111 ++++++++++++++++++ 2 files changed, 148 insertions(+), 15 deletions(-) create mode 100644 test/sdk/core/knowledge_base/test_aidp_client.py diff --git a/sdk/nexent/core/knowledge_base/aidp_client.py b/sdk/nexent/core/knowledge_base/aidp_client.py index b5414841e..bbcbb2e6f 100644 --- a/sdk/nexent/core/knowledge_base/aidp_client.py +++ b/sdk/nexent/core/knowledge_base/aidp_client.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import time from typing import Any from urllib.parse import urljoin @@ -15,6 +16,8 @@ logger = logging.getLogger("aidp_knowledge_base_adapter") +_RETRY_DELAYS_SECONDS = (1, 2, 4) + class AidpAdapterError(RuntimeError): """Raised when the adapter cannot complete an AIDP request.""" @@ -61,21 +64,40 @@ def _url(self, path: str) -> str: def _request(self, method: str, path: str, **kwargs: Any) -> Any: url = self._url(path) - try: - response = self._client.request(method, url, **kwargs) - response.raise_for_status() - except httpx.HTTPStatusError as exc: - body = self._safe_json(exc.response) - message = self._extract_error_message(body) or str(exc) - logger.warning("AIDP HTTP error %s for %s %s: %s", exc.response.status_code, method, path, message) - raise AidpAdapterError(message, exc.response.status_code, body) from exc - except httpx.RequestError as exc: - logger.warning("AIDP request error for %s %s: %s", method, path, exc) - raise AidpAdapterError(f"AIDP request failed: {exc}", 503) from exc - - if response.status_code == 204 or not response.content: - return {} - return self._safe_json(response) + for attempt, retry_delay in enumerate((*_RETRY_DELAYS_SECONDS, None), start=1): + try: + response = self._client.request(method, url, **kwargs) + except httpx.RequestError as exc: + if retry_delay is None: + raise AidpAdapterError(f"AIDP request failed: {exc}", 503) from exc + logger.warning( + "AIDP request attempt %d/4 failed for %s %s: %s; retrying in %ds", + attempt, + method, + path, + exc, + retry_delay, + ) + time.sleep(retry_delay) + continue + + if response.status_code == 200: + return {} if not response.content else self._safe_json(response) + + if retry_delay is None: + body = self._safe_json(response) + message = self._extract_error_message(body) or f"AIDP HTTP error {response.status_code}" + raise AidpAdapterError(message, response.status_code, body) + + logger.warning( + "AIDP HTTP attempt %d/4 failed for %s %s with status %d; retrying in %ds", + attempt, + method, + path, + response.status_code, + retry_delay, + ) + time.sleep(retry_delay) @staticmethod def _safe_json(response: httpx.Response) -> Any: diff --git a/test/sdk/core/knowledge_base/test_aidp_client.py b/test/sdk/core/knowledge_base/test_aidp_client.py new file mode 100644 index 000000000..f2b0a2cef --- /dev/null +++ b/test/sdk/core/knowledge_base/test_aidp_client.py @@ -0,0 +1,111 @@ +import importlib.util +import json +import sys +import types +from pathlib import Path +from unittest.mock import MagicMock, patch + +import httpx +import pytest + + +def _load_aidp_client_module(): + repo_root = Path(__file__).resolve().parents[4] + sdk_root = repo_root / "sdk" / "nexent" + package_dir = sdk_root / "core" / "knowledge_base" + packages = { + "nexent": sdk_root, + "nexent.core": sdk_root / "core", + "nexent.core.knowledge_base": package_dir, + "nexent.utils": sdk_root / "utils", + } + for name, path in packages.items(): + module = sys.modules.get(name) or types.ModuleType(name) + module.__path__ = [str(path)] + sys.modules[name] = module + + for module_name, path in { + "nexent.utils.http_client_manager": sdk_root / "utils" / "http_client_manager.py", + "nexent.core.knowledge_base.config": package_dir / "config.py", + "nexent.core.knowledge_base.aidp_client": package_dir / "aidp_client.py", + }.items(): + spec = importlib.util.spec_from_file_location(module_name, path) + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return sys.modules["nexent.core.knowledge_base.aidp_client"] + + +aidp_client_module = _load_aidp_client_module() +AidpAdapterError = aidp_client_module.AidpAdapterError +AidpClient = aidp_client_module.AidpClient + + +def _response(status_code: int, body: dict) -> httpx.Response: + request = httpx.Request("GET", "https://aidp.example.com/resource") + return httpx.Response(status_code, content=json.dumps(body), request=request) + + +@pytest.fixture +def client() -> AidpClient: + aidp_client = AidpClient.__new__(AidpClient) + aidp_client.base_url = "https://aidp.example.com" + aidp_client._client = MagicMock() + return aidp_client + + +def test_request_retries_non_200_until_a_200_response(client: AidpClient): + client._client.request.side_effect = [ + _response(503, {"message": "unavailable"}), + _response(429, {"message": "busy"}), + _response(200, {"value": "ok"}), + ] + + with patch.object(aidp_client_module, "time", create=True) as time_module: + sleep = time_module.sleep + result = client._request("GET", "/resource") + + assert result == {"value": "ok"} + assert client._client.request.call_count == 3 + assert sleep.call_args_list == [((1,),), ((2,),)] + + +def test_request_raises_final_non_200_after_four_attempts(client: AidpClient): + client._client.request.return_value = _response(503, {"message": "unavailable"}) + + with patch.object(aidp_client_module, "time", create=True) as time_module: + sleep = time_module.sleep + with pytest.raises(AidpAdapterError) as error: + client._request("GET", "/resource") + + assert error.value.status_code == 503 + assert error.value.response_body == {"message": "unavailable"} + assert client._client.request.call_count == 4 + assert sleep.call_args_list == [((1,),), ((2,),), ((4,),)] + + +def test_request_retries_request_errors_until_a_200_response(client: AidpClient): + client._client.request.side_effect = [ + httpx.ConnectError("offline"), + _response(200, {"value": "ok"}), + ] + + with patch.object(aidp_client_module, "time", create=True) as time_module: + sleep = time_module.sleep + result = client._request("GET", "/resource") + + assert result == {"value": "ok"} + assert client._client.request.call_count == 2 + sleep.assert_called_once_with(1) + + +def test_request_does_not_retry_a_200_response(client: AidpClient): + client._client.request.return_value = _response(200, {"value": "ok"}) + + with patch.object(aidp_client_module, "time", create=True) as time_module: + sleep = time_module.sleep + result = client._request("GET", "/resource") + + assert result == {"value": "ok"} + client._client.request.assert_called_once() + sleep.assert_not_called()