feat: add Mihomo API client with refresh polling

Implements MihomoClient wrapping Mihomo's REST API with provider refresh
polling, config reload, and startup readiness check. Also fixes a
Python 3.14 + respx 0.21.1 compatibility issue where the httpcore mocker
passes bytes method to httpx.Request causing Method pattern matching to
fail; resolved by switching respx DEFAULT_MOCKER to the httpx-level mocker.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 23:49:17 +03:00
parent 927abe6111
commit 21437c78ad
3 changed files with 175 additions and 0 deletions
+69
View File
@@ -0,0 +1,69 @@
import asyncio
import logging
import httpx
logger = logging.getLogger(__name__)
class MihomoClient:
def __init__(self, base_url: str, secret: str):
self._base_url = base_url.rstrip("/")
self._headers = {"Authorization": f"Bearer {secret}"} if secret else {}
async def trigger_provider_refresh(self, name: str) -> None:
async with httpx.AsyncClient() as c:
resp = await c.put(
f"{self._base_url}/providers/proxies/{name}",
headers=self._headers,
timeout=10.0,
)
resp.raise_for_status()
async def get_provider(self, name: str) -> dict:
async with httpx.AsyncClient() as c:
resp = await c.get(
f"{self._base_url}/providers/proxies/{name}",
headers=self._headers,
timeout=10.0,
)
resp.raise_for_status()
return resp.json()
async def refresh_and_collect(self, name: str, timeout: int = 30) -> list[dict]:
initial = await self.get_provider(name)
initial_ts = initial.get("updatedAt", "")
await self.trigger_provider_refresh(name)
loop = asyncio.get_event_loop()
deadline = loop.time() + timeout
while loop.time() < deadline:
await asyncio.sleep(1)
data = await self.get_provider(name)
if data.get("updatedAt", "") != initial_ts:
return data.get("proxies", [])
raise TimeoutError(f"Provider {name!r} did not refresh within {timeout}s")
async def reload_config(self) -> None:
async with httpx.AsyncClient() as c:
resp = await c.put(
f"{self._base_url}/configs",
json={},
headers=self._headers,
timeout=10.0,
)
resp.raise_for_status()
async def wait_ready(self, retries: int = 60) -> None:
for i in range(retries):
try:
async with httpx.AsyncClient() as c:
resp = await c.get(f"{self._base_url}/version", timeout=3.0)
resp.raise_for_status()
return
except Exception:
logger.info("Waiting for Mihomo... (%d/%d)", i + 1, retries)
await asyncio.sleep(1)
raise RuntimeError("Mihomo did not become available")
+8
View File
@@ -2,6 +2,14 @@ import sys
import os import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import respx.mocks
# Python 3.14 + httpx 0.28 compatibility: httpcore passes method as bytes,
# but the httpcore mocker reconstructs httpx.Request with bytes method,
# causing Method pattern comparison ('GET' == b'GET') to always fail.
# Use the httpx-level mocker instead, which patches AsyncClient._transport_for_url
# and avoids the bytes/str mismatch entirely.
respx.mocks.DEFAULT_MOCKER = "httpx"
import pytest_asyncio import pytest_asyncio
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from models import Base from models import Base
+98
View File
@@ -0,0 +1,98 @@
import pytest
import respx
import httpx
from mihomo import MihomoClient
@pytest.fixture
def client():
return MihomoClient("http://mihomo:9090", "secret123")
async def test_trigger_provider_refresh(client):
with respx.mock:
respx.put("http://mihomo:9090/providers/proxies/myprovider").mock(
return_value=httpx.Response(204)
)
await client.trigger_provider_refresh("myprovider")
async def test_get_provider(client):
payload = {
"name": "myprovider",
"updatedAt": "2024-01-01T00:00:00Z",
"proxies": [{"name": "node1", "type": "ss"}],
}
with respx.mock:
respx.get("http://mihomo:9090/providers/proxies/myprovider").mock(
return_value=httpx.Response(200, json=payload)
)
data = await client.get_provider("myprovider")
assert data["updatedAt"] == "2024-01-01T00:00:00Z"
async def test_refresh_and_collect_detects_update(client):
call_count = 0
def provider_handler(request):
nonlocal call_count
call_count += 1
if call_count == 1:
return httpx.Response(200, json={"updatedAt": "t1", "proxies": []})
return httpx.Response(
200,
json={
"updatedAt": "t2",
"proxies": [
{"name": "n1", "type": "ss", "server": "1.2.3.4", "port": 443}
],
},
)
with respx.mock:
respx.put("http://mihomo:9090/providers/proxies/myprovider").mock(
return_value=httpx.Response(204)
)
respx.get("http://mihomo:9090/providers/proxies/myprovider").mock(
side_effect=provider_handler
)
proxies = await client.refresh_and_collect("myprovider", timeout=5)
assert len(proxies) == 1
assert proxies[0]["name"] == "n1"
async def test_refresh_and_collect_timeout(client):
with respx.mock:
respx.put("http://mihomo:9090/providers/proxies/slow").mock(
return_value=httpx.Response(204)
)
respx.get("http://mihomo:9090/providers/proxies/slow").mock(
return_value=httpx.Response(200, json={"updatedAt": "never-changes", "proxies": []})
)
with pytest.raises(TimeoutError):
await client.refresh_and_collect("slow", timeout=2)
async def test_reload_config(client):
with respx.mock:
respx.put("http://mihomo:9090/configs").mock(return_value=httpx.Response(204))
await client.reload_config()
async def test_wait_ready_succeeds(client):
with respx.mock:
respx.get("http://mihomo:9090/version").mock(
return_value=httpx.Response(200, json={"version": "1.0"})
)
await client.wait_ready(retries=3)
async def test_wait_ready_raises_after_retries():
slow_client = MihomoClient("http://missing:9090", "")
with respx.mock:
respx.get("http://missing:9090/version").mock(
return_value=httpx.Response(503)
)
with pytest.raises(RuntimeError, match="did not become available"):
await slow_client.wait_ready(retries=2)