Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 37 additions & 15 deletions sdk/nexent/core/knowledge_base/aidp_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import logging
import time
from typing import Any
from urllib.parse import urljoin

Expand All @@ -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."""
Expand Down Expand Up @@ -61,21 +64,40 @@

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,
)

Check warning on line 80 in sdk/nexent/core/knowledge_base/aidp_client.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Change this code to not log user-controlled data.

See more on https://sonarcloud.io/project/issues?id=ModelEngine-Group_nexent&issues=AZ9jqNVJKu499Cc-ZKYl&open=AZ9jqNVJKu499Cc-ZKYl&pullRequest=3428
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,
)

Check warning on line 99 in sdk/nexent/core/knowledge_base/aidp_client.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Change this code to not log user-controlled data.

See more on https://sonarcloud.io/project/issues?id=ModelEngine-Group_nexent&issues=AZ9jqNVJKu499Cc-ZKYk&open=AZ9jqNVJKu499Cc-ZKYk&pullRequest=3428
time.sleep(retry_delay)

@staticmethod
def _safe_json(response: httpx.Response) -> Any:
Expand Down
111 changes: 111 additions & 0 deletions test/sdk/core/knowledge_base/test_aidp_client.py
Original file line number Diff line number Diff line change
@@ -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()