From 21437c78ad2b6e9b9af02c79df0840b695eb76b5 Mon Sep 17 00:00:00 2001 From: urbnywrt Date: Thu, 14 May 2026 23:49:17 +0300 Subject: [PATCH] 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 --- app/mihomo.py | 69 ++++++++++++++++++++++++++++ app/tests/conftest.py | 8 ++++ app/tests/test_mihomo.py | 98 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 175 insertions(+) create mode 100644 app/mihomo.py create mode 100644 app/tests/test_mihomo.py diff --git a/app/mihomo.py b/app/mihomo.py new file mode 100644 index 0000000..3df585a --- /dev/null +++ b/app/mihomo.py @@ -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") diff --git a/app/tests/conftest.py b/app/tests/conftest.py index 0112355..81f53e2 100644 --- a/app/tests/conftest.py +++ b/app/tests/conftest.py @@ -2,6 +2,14 @@ import sys import os 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 from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker from models import Base diff --git a/app/tests/test_mihomo.py b/app/tests/test_mihomo.py new file mode 100644 index 0000000..97de811 --- /dev/null +++ b/app/tests/test_mihomo.py @@ -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)