From 93a368b1a2189faf2b40dbade8fec7a543d716b5 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Fri, 13 Mar 2026 18:39:06 +0800 Subject: [PATCH] fix(admin): handle routstr balance timeouts --- routstr/core/admin.py | 95 ++++++++++++++++++----------- routstr/upstream/routstr.py | 2 +- tests/unit/test_upstream_routstr.py | 70 +++++++++++++++++++++ 3 files changed, 131 insertions(+), 36 deletions(-) create mode 100644 tests/unit/test_upstream_routstr.py diff --git a/routstr/core/admin.py b/routstr/core/admin.py index c3160c9e..572b035c 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -1,3 +1,4 @@ +import asyncio import json import secrets from datetime import datetime, timezone @@ -865,12 +866,6 @@ async def initiate_provider_topup( if not provider: raise HTTPException(status_code=404, detail="Provider not found") - upstream_instance = _instantiate_provider(provider) - if not upstream_instance: - raise HTTPException( - status_code=400, detail="Could not instantiate provider" - ) - try: logger.info( f"Initiating top-up for provider {provider_id}", @@ -884,39 +879,69 @@ async def initiate_provider_topup( async with httpx.AsyncClient() as client: clean_url = provider.base_url.rstrip("/") - # Proxy the request to upstream Routstr - # Use the actual API key from the database - resp = await client.post( - f"{clean_url}/v1/balance/lightning/invoice", - json={ - "amount_sats": int(payload.amount), - "purpose": "topup", - "api_key": provider.api_key, - }, - headers={"Authorization": f"Bearer {provider.api_key}"} if provider.api_key else {}, + request_json = { + "amount_sats": int(payload.amount), + "purpose": "topup", + "api_key": provider.api_key, + } + headers = ( + {"Authorization": f"Bearer {provider.api_key}"} + if provider.api_key + else {} ) - if resp.status_code == 200: - data = resp.json() - return { - "ok": True, - "topup_data": { - "payment_request": data.get("bolt11"), - "invoice_id": data.get("invoice_id"), - "status": "pending", - }, - } - else: - logger.error(f"Upstream topup request failed: {resp.text}") - # Check if it's JSON error - try: - error_detail = resp.json() - except Exception: - error_detail = resp.text - raise HTTPException( - status_code=resp.status_code, detail=error_detail + last_status_code = 500 + last_error_detail: object = "Failed to create top-up invoice" + + # Some upstream Routstr nodes fail the first invoice request after warm-up + # and succeed immediately on retry. Retry once here so the UI stays single-click. + for attempt in range(2): + resp = await client.post( + f"{clean_url}/v1/balance/lightning/invoice", + json=request_json, + headers=headers, ) + if resp.status_code == 200: + data = resp.json() + return { + "ok": True, + "topup_data": { + "payment_request": data.get("bolt11"), + "invoice_id": data.get("invoice_id"), + "status": "pending", + }, + } + + logger.error( + f"Upstream topup request failed: {resp.text}", + extra={ + "provider_id": provider_id, + "attempt": attempt + 1, + "status_code": resp.status_code, + }, + ) + try: + last_error_detail = resp.json() + except Exception: + last_error_detail = resp.text + last_status_code = resp.status_code + + if resp.status_code < 500 or attempt == 1: + break + + await asyncio.sleep(0.2) + + raise HTTPException( + status_code=last_status_code, detail=last_error_detail + ) + + upstream_instance = _instantiate_provider(provider) + if not upstream_instance: + raise HTTPException( + status_code=400, detail="Could not instantiate provider" + ) + topup_data = await upstream_instance.initiate_topup(payload.amount) logger.info( diff --git a/routstr/upstream/routstr.py b/routstr/upstream/routstr.py index abf82a33..ab6dd0bd 100644 --- a/routstr/upstream/routstr.py +++ b/routstr/upstream/routstr.py @@ -83,7 +83,7 @@ class RoutstrUpstreamProvider(BaseUpstreamProvider): Balance in satoshis, or None if failed """ url = f"{self.base_url}/v1/balance/info" - headers = {"Authorization": f"Bearer {self.api_key}"} + headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {} async with httpx.AsyncClient() as client: try: diff --git a/tests/unit/test_upstream_routstr.py b/tests/unit/test_upstream_routstr.py new file mode 100644 index 00000000..d544fb2e --- /dev/null +++ b/tests/unit/test_upstream_routstr.py @@ -0,0 +1,70 @@ +from unittest.mock import Mock + +import httpx +import pytest + +from routstr.upstream.routstr import RoutstrUpstreamProvider + + +class DummyAsyncClient: + def __init__(self, response: Mock | None = None, error: Exception | None = None): + self.response = response + self.error = error + self.calls: list[dict[str, object]] = [] + + async def __aenter__(self) -> "DummyAsyncClient": + return self + + async def __aexit__(self, exc_type, exc, tb) -> bool: + return False + + async def get( + self, url: str, headers: dict[str, str], timeout: float + ) -> Mock: + self.calls.append({"url": url, "headers": headers, "timeout": timeout}) + if self.error is not None: + raise self.error + assert self.response is not None + return self.response + + +@pytest.mark.asyncio +async def test_get_balance_omits_auth_header_when_api_key_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + response = Mock() + response.json.return_value = {"balance_msats": 42000} + response.raise_for_status.return_value = None + + client = DummyAsyncClient(response=response) + monkeypatch.setattr("routstr.upstream.routstr.httpx.AsyncClient", lambda: client) + + provider = RoutstrUpstreamProvider(base_url="https://node.example", api_key="") + + balance = await provider.get_balance() + + assert balance == 42.0 + assert client.calls == [ + { + "url": "https://node.example/v1/balance/info", + "headers": {}, + "timeout": 10.0, + } + ] + + +@pytest.mark.asyncio +async def test_get_balance_returns_none_on_connect_timeout( + monkeypatch: pytest.MonkeyPatch, +) -> None: + client = DummyAsyncClient(error=httpx.ConnectTimeout("timed out")) + monkeypatch.setattr("routstr.upstream.routstr.httpx.AsyncClient", lambda: client) + + provider = RoutstrUpstreamProvider( + base_url="https://node.example", + api_key="secret", + ) + + balance = await provider.get_balance() + + assert balance is None