From 59f8d31719ad9793b8ed071eafd5149d494b0015 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Fri, 13 Mar 2026 18:45:18 +0800 Subject: [PATCH 1/2] Handle Routstr admin balance timeouts --- routstr/core/admin.py | 49 +++++++++--- .../test_admin_provider_balance.py | 79 +++++++++++++++++++ 2 files changed, 119 insertions(+), 9 deletions(-) create mode 100644 tests/integration/test_admin_provider_balance.py diff --git a/routstr/core/admin.py b/routstr/core/admin.py index c3160c9e..81ac8309 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -1025,15 +1025,46 @@ async def get_provider_balance(provider_id: int) -> dict[str, object]: if provider.provider_type == "routstr": import httpx - async with httpx.AsyncClient() as client: - clean_url = provider.base_url.rstrip("/") - headers = {} - if provider.api_key: - headers["Authorization"] = f"Bearer {provider.api_key}" - resp = await client.get( - f"{clean_url}/v1/balance/info", - headers=headers, - ) + clean_url = provider.base_url.rstrip("/") + headers = {} + if provider.api_key: + headers["Authorization"] = f"Bearer {provider.api_key}" + + async with httpx.AsyncClient(timeout=10.0) as client: + try: + resp = await client.get( + f"{clean_url}/v1/balance/info", + headers=headers, + ) + except httpx.TimeoutException as exc: + logger.error( + "Timed out fetching Routstr provider balance", + extra={ + "provider_id": provider_id, + "base_url": clean_url, + "upstream_url": f"{clean_url}/v1/balance/info", + "error": str(exc), + }, + ) + raise HTTPException( + status_code=504, + detail="Timed out contacting upstream Routstr provider", + ) from exc + except httpx.RequestError as exc: + logger.error( + "Failed to fetch Routstr provider balance", + extra={ + "provider_id": provider_id, + "base_url": clean_url, + "upstream_url": f"{clean_url}/v1/balance/info", + "error": str(exc), + }, + ) + raise HTTPException( + status_code=502, + detail="Failed to contact upstream Routstr provider", + ) from exc + if resp.status_code == 200: data = resp.json() # Return balance in sats diff --git a/tests/integration/test_admin_provider_balance.py b/tests/integration/test_admin_provider_balance.py new file mode 100644 index 00000000..4167e68e --- /dev/null +++ b/tests/integration/test_admin_provider_balance.py @@ -0,0 +1,79 @@ +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, patch + +import httpx +import pytest + +from routstr.core.admin import admin_sessions +from routstr.core.db import UpstreamProviderRow + + +async def _create_routstr_provider() -> UpstreamProviderRow: + return UpstreamProviderRow( + provider_type="routstr", + base_url="https://upstream.example", + api_key="", + enabled=True, + ) + + +def _admin_headers() -> dict[str, str]: + token = "test-admin-token" + admin_sessions[token] = int( + (datetime.now(timezone.utc) + timedelta(minutes=5)).timestamp() + ) + return {"Authorization": f"Bearer {token}"} + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_admin_routstr_provider_balance_timeout_returns_504( + integration_client: httpx.AsyncClient, + integration_session, +) -> None: + provider = await _create_routstr_provider() + integration_session.add(provider) + await integration_session.commit() + await integration_session.refresh(provider) + + request = httpx.Request("GET", f"{provider.base_url}/v1/balance/info") + timeout_error = httpx.ConnectTimeout("Connect timeout", request=request) + + with patch( + "httpx.AsyncHTTPTransport.handle_async_request", + new=AsyncMock(side_effect=timeout_error), + ): + response = await integration_client.get( + f"/admin/api/upstream-providers/{provider.id}/balance", + headers=_admin_headers(), + ) + + assert response.status_code == 504 + assert response.json()["detail"] == "Timed out contacting upstream Routstr provider" + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_admin_routstr_provider_balance_request_error_returns_502( + integration_client: httpx.AsyncClient, + integration_session, +) -> None: + provider = await _create_routstr_provider() + integration_session.add(provider) + await integration_session.commit() + await integration_session.refresh(provider) + + request = httpx.Request("GET", f"{provider.base_url}/v1/balance/info") + request_error = httpx.ConnectError("Connection failed", request=request) + + with patch( + "httpx.AsyncHTTPTransport.handle_async_request", + new=AsyncMock(side_effect=request_error), + ): + response = await integration_client.get( + f"/admin/api/upstream-providers/{provider.id}/balance", + headers=_admin_headers(), + ) + + assert response.status_code == 502 + assert response.json()["detail"] == "Failed to contact upstream Routstr provider" From dd88e9b172b081d60defc0d778e265c39c701495 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Fri, 13 Mar 2026 18:51:30 +0800 Subject: [PATCH 2/2] test: annotate admin balance integration session --- tests/integration/test_admin_provider_balance.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/integration/test_admin_provider_balance.py b/tests/integration/test_admin_provider_balance.py index 4167e68e..c5e94fab 100644 --- a/tests/integration/test_admin_provider_balance.py +++ b/tests/integration/test_admin_provider_balance.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, patch import httpx import pytest +from sqlmodel.ext.asyncio.session import AsyncSession from routstr.core.admin import admin_sessions from routstr.core.db import UpstreamProviderRow @@ -29,7 +30,7 @@ def _admin_headers() -> dict[str, str]: @pytest.mark.asyncio async def test_admin_routstr_provider_balance_timeout_returns_504( integration_client: httpx.AsyncClient, - integration_session, + integration_session: AsyncSession, ) -> None: provider = await _create_routstr_provider() integration_session.add(provider) @@ -56,7 +57,7 @@ async def test_admin_routstr_provider_balance_timeout_returns_504( @pytest.mark.asyncio async def test_admin_routstr_provider_balance_request_error_returns_502( integration_client: httpx.AsyncClient, - integration_session, + integration_session: AsyncSession, ) -> None: provider = await _create_routstr_provider() integration_session.add(provider)