fix: retry transient Routstr top-up invoice failures

This commit is contained in:
Shroominic
2026-03-13 18:17:19 +08:00
parent 7e648cb5c2
commit 0198569a9a
2 changed files with 153 additions and 59 deletions
+70 -59
View File
@@ -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(
@@ -1021,30 +1046,6 @@ async def get_provider_balance(provider_id: int) -> dict[str, object]:
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
# For Routstr providers, proxy the balance check
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,
)
if resp.status_code == 200:
data = resp.json()
# Return balance in sats
balance = data.get("balance", 0)
if isinstance(balance, (int, float)):
return {"ok": True, "balance_data": balance // 1000}
return {"ok": True, "balance_data": balance}
else:
logger.error(f"Failed to fetch Routstr balance: {resp.text}")
return {"ok": False, "balance_data": None}
upstream_instance = _instantiate_provider(provider)
if not upstream_instance:
raise HTTPException(
@@ -1053,6 +1054,16 @@ async def get_provider_balance(provider_id: int) -> dict[str, object]:
try:
balance_data = await upstream_instance.get_balance()
if balance_data is None:
logger.warning(
"Balance check returned no data",
extra={
"provider_id": provider_id,
"provider_type": provider.provider_type,
"base_url": provider.base_url,
},
)
return {"ok": False, "balance_data": None}
return {"ok": True, "balance_data": balance_data}
except NotImplementedError as e:
raise HTTPException(
@@ -3,12 +3,15 @@ Integration tests for provider management functionality.
Tests GET /v1/providers/ endpoint for listing and managing providers.
"""
import time
from typing import Any, Generator
from unittest.mock import patch
import pytest
from httpx import AsyncClient
from routstr.core.admin import admin_sessions
from routstr.core.db import UpstreamProviderRow
from routstr.nostr.discovery import _PROVIDERS_CACHE
from .utils import ResponseValidator
@@ -678,3 +681,83 @@ async def test_no_database_changes_during_provider_operations(
assert final_diff["api_keys"]["added"] == []
assert final_diff["api_keys"]["modified"] == []
assert final_diff["api_keys"]["removed"] == []
@pytest.mark.integration
@pytest.mark.asyncio
async def test_admin_routstr_topup_retries_transient_upstream_failure(
integration_client: AsyncClient,
integration_session: Any,
) -> None:
admin_token = "test-admin-token"
admin_sessions[admin_token] = int(time.time()) + 3600
integration_client.headers["Authorization"] = f"Bearer {admin_token}"
provider = UpstreamProviderRow(
provider_type="routstr",
base_url="https://node.example",
api_key="sk-upstream-test",
enabled=True,
provider_fee=1.01,
)
integration_session.add(provider)
await integration_session.commit()
await integration_session.refresh(provider)
class MockResponse:
def __init__(self, status_code: int, data: dict[str, Any] | None = None):
self.status_code = status_code
self._data = data or {}
self.text = str(self._data)
def json(self) -> dict[str, Any]:
return self._data
class MockAsyncClient:
def __init__(self):
self.calls = 0
async def __aenter__(self) -> "MockAsyncClient":
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
return None
async def post(
self, url: str, json: dict[str, Any], headers: dict[str, str]
) -> MockResponse:
self.calls += 1
assert url == "https://node.example/v1/balance/lightning/invoice"
assert json["amount_sats"] == 10
assert json["purpose"] == "topup"
assert json["api_key"] == "sk-upstream-test"
assert headers["Authorization"] == "Bearer sk-upstream-test"
if self.calls == 1:
return MockResponse(500, {"detail": "warmup failure"})
return MockResponse(
200,
{
"bolt11": "lnbc1testinvoice",
"invoice_id": "invoice-123",
},
)
mock_client = MockAsyncClient()
try:
with patch("httpx.AsyncClient", return_value=mock_client):
response = await integration_client.post(
f"/admin/api/upstream-providers/{provider.id}/topup",
json={"amount": 10},
)
assert response.status_code == 200
data = response.json()
assert data["ok"] is True
assert data["topup_data"]["payment_request"] == "lnbc1testinvoice"
assert data["topup_data"]["invoice_id"] == "invoice-123"
assert mock_client.calls == 2
finally:
admin_sessions.pop(admin_token, None)