Skip to content
Merged
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
9 changes: 9 additions & 0 deletions src/malwar/crawl/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@

BASE_URL = "https://clawhub.ai/api/v1"
_TIMEOUT = 15.0
# Transient connection failures (ConnectTimeout/ConnectError) are common when
# sweeping thousands of skills from CI; retry them at the transport level so a
# single blip does not abort a long registry crawl.
_RETRIES = 3
_USER_AGENT = f"malwar/{__version__}"


Expand Down Expand Up @@ -76,13 +80,18 @@ def __init__(
self,
base_url: str = BASE_URL,
timeout: float = _TIMEOUT,
retries: int = _RETRIES,
) -> None:
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.retries = retries

def _client(self) -> httpx.AsyncClient:
# AsyncHTTPTransport retries connection-level failures — ConnectError
# and ConnectTimeout (a subclass) — with exponential backoff.
return httpx.AsyncClient(
timeout=httpx.Timeout(self.timeout),
transport=httpx.AsyncHTTPTransport(retries=self.retries),
headers={"User-Agent": _USER_AGENT},
)

Expand Down
6 changes: 3 additions & 3 deletions src/malwar/monitor/snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
from collections.abc import Callable
from pathlib import Path

from malwar.crawl.client import ClawHubClient, ClawHubError
from malwar.crawl.client import ClawHubClient
from malwar.monitor.models import RegistrySnapshot, SkillRecord
from malwar.sdk import scan

Expand Down Expand Up @@ -65,7 +65,7 @@ async def _enumerate_skills(
while True:
try:
items, cursor = await client.list_skills(limit=page_size, cursor=cursor)
except ClawHubError as exc:
except Exception as exc: # ClawHubError, httpx timeouts after retries, etc.
logger.warning("list_skills failed: %s", exc)
break
for item in items:
Expand All @@ -85,7 +85,7 @@ async def _enumerate_skills(
for term in _SEED_TERMS:
try:
results = await client.search(term, limit=page_size)
except ClawHubError as exc:
except Exception as exc: # ClawHubError, httpx timeouts after retries, etc.
logger.warning("search %r failed: %s", term, exc)
continue
for r in results:
Expand Down
15 changes: 15 additions & 0 deletions tests/unit/crawl/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,3 +266,18 @@ async def test_http_error(self):
with patch("malwar.crawl.client.httpx.AsyncClient", return_value=mc), \
pytest.raises(ClawHubError):
await fetch_url("https://example.com/missing.md")


class TestConnectionResilience:
"""The client retries transient connection failures at the transport level."""

def test_defaults_to_retrying_transport(self):
client = ClawHubClient()
assert client.retries == 3
# _client() wires the retrying transport without error.
real = client._client()
assert isinstance(real, httpx.AsyncClient)

def test_retries_configurable(self):
assert ClawHubClient(retries=0).retries == 0
assert ClawHubClient(retries=5).retries == 5
12 changes: 12 additions & 0 deletions tests/unit/test_monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,18 @@ async def _boom(slug, path="SKILL.md", version=None):
assert snap.skills["good"].verdict == "CLEAN"
assert snap.skills["bad"].error is not None

async def test_enumeration_timeout_does_not_crash(self):
# A raw timeout while listing the registry must not abort the run.
client = FakeClawHubClient({"a": BENIGN_BODY})

async def _boom(limit=20, cursor=None):
raise TimeoutError("connect timeout")

client.list_skills = _boom # type: ignore[assignment]
# search fallback returns [] in the fake → empty snapshot, no exception.
snap = await build_snapshot(client, escalate=False)
assert snap.skill_count == 0


class TestIncrementalSweep:
async def test_unchanged_skills_are_not_refetched(self):
Expand Down
Loading