show mint status correclty

This commit is contained in:
9qeklajc
2026-07-15 01:00:32 +02:00
parent 09e1c7bf2d
commit 8b942f3c14
5 changed files with 284 additions and 48 deletions
+148 -35
View File
@@ -89,18 +89,26 @@ class _MintRateGuard:
asyncio.Semaphore(max_concurrency) if max_concurrency > 0 else None
)
self._cooldown_until = 0.0
self._cooldown_reason: str | None = None
self._needs_probe = False
self._probe_lock = asyncio.Lock()
def apply_cooldown(self, delay: float) -> None:
self._cooldown_until = max(
self._cooldown_until, time.monotonic() + max(0.0, delay)
)
def apply_cooldown(self, delay: float, *, reason: str | None = None) -> None:
deadline = time.monotonic() + max(0.0, delay)
if deadline >= self._cooldown_until:
self._cooldown_until = deadline
if reason is not None:
self._cooldown_reason = reason
elif self._cooldown_reason is None and reason is not None:
self._cooldown_reason = reason
self._needs_probe = True
def cooldown_remaining(self) -> float:
return max(0.0, self._cooldown_until - time.monotonic())
def cooldown_reason(self) -> str | None:
return self._cooldown_reason if self.cooldown_remaining() > 0 else None
async def _wait_for_cooldown(self) -> None:
while True:
deadline = self._cooldown_until
@@ -140,6 +148,7 @@ class _MintRateGuard:
self._needs_probe = False
self._cooldown_until = 0.0
self._cooldown_reason = None
logger.warning(
"Mint cooldown probe succeeded; restoring normal concurrency",
extra={
@@ -171,6 +180,10 @@ def _mint_cooldown_remaining(mint_url: str) -> float:
return _MintRateGuard.get(mint_url).cooldown_remaining()
def _mint_cooldown_reason(mint_url: str) -> str | None:
return _MintRateGuard.get(mint_url).cooldown_reason()
def _is_mint_rate_limited(error: BaseException) -> bool:
"""True if the mint returned a 429 or rate-limit indication."""
current: BaseException | None = error
@@ -248,7 +261,7 @@ async def _mint_operation(
if retry_after is not None:
backoff = max(retry_after, backoff)
if guard is not None:
guard.apply_cooldown(backoff)
guard.apply_cooldown(backoff, reason="rate_limited")
# When the caller has a fallback strategy (trusted-mint
# list), re-raise immediately so the caller can try the next
@@ -458,9 +471,7 @@ async def _redeem_same_mint(
wallet.verify_proofs_dleq(token_obj.proofs)
input_fees = wallet.get_fees_for_proofs(token_obj.proofs)
await _mint_operation(
lambda: wallet.split(
proofs=token_obj.proofs, amount=0, include_fees=True
),
lambda: wallet.split(proofs=token_obj.proofs, amount=0, include_fees=True),
op_name="redeem_split",
mint_url=token_obj.mint,
retry_timeouts=False,
@@ -714,9 +725,7 @@ async def _request_mint_with_fallback(
f"_request_mint_with_fallback({op_name}): amount must be > 0, got {amount}. "
f"Token value is too small after fee deduction or unit conversion."
)
candidates = list(
dict.fromkeys([settings.primary_mint, *settings.cashu_mints])
)
candidates = list(dict.fromkeys([settings.primary_mint, *settings.cashu_mints]))
logger.warning(
"Trying trusted destination mints",
extra={
@@ -788,7 +797,7 @@ async def _request_mint_with_fallback(
raise
if connection_failure:
_MintRateGuard.get(mint_url).apply_cooldown(
_MINT_TRANSPORT_COOLDOWN_SECONDS
_MINT_TRANSPORT_COOLDOWN_SECONDS, reason="unreachable"
)
logger.warning(
"Destination mint failed",
@@ -1504,22 +1513,68 @@ class BalanceDetail(TypedDict, total=False):
user_balance: int
owner_balance: int
error: str
error_code: str
retry_after_seconds: float
_BALANCE_FETCH_RETRY_SECONDS = 60.0
_balance_fetch_failures: dict[tuple[str, str], tuple[float, str]] = {}
_MINT_UNITS_CACHE_SECONDS = 300.0
_balance_fetch_failures: dict[tuple[str, str], tuple[float, str, str]] = {}
_balance_fetch_locks: dict[str, asyncio.Lock] = {}
_mint_supported_units: dict[str, tuple[float, list[str]]] = {}
def _balance_error(mint_url: str, unit: str, error: str) -> BalanceDetail:
return {
async def _get_supported_mint_units(mint_url: str) -> list[str]:
now = time.monotonic()
cached = _mint_supported_units.get(mint_url)
if cached is not None and now < cached[0]:
return cached[1]
wallet = await get_wallet(mint_url, settings.primary_mint_unit, load=False)
keysets = await _mint_operation(
lambda: wallet._get_keysets(),
op_name="get_mint_keysets",
mint_url=mint_url,
retry_on_rate_limit=False,
)
units = list(
dict.fromkeys(
keyset.unit.name for keyset in keysets if keyset.active and keyset.unit.name
)
)
if not units:
units = [settings.primary_mint_unit]
elif settings.primary_mint_unit in units:
units.remove(settings.primary_mint_unit)
units.insert(0, settings.primary_mint_unit)
_mint_supported_units[mint_url] = (
time.monotonic() + _MINT_UNITS_CACHE_SECONDS,
units,
)
return units
def _balance_error(
mint_url: str,
unit: str,
error: str,
*,
error_code: str,
retry_after_seconds: float | None = None,
) -> BalanceDetail:
detail: BalanceDetail = {
"mint_url": mint_url,
"unit": unit,
"wallet_balance": 0,
"user_balance": 0,
"owner_balance": 0,
"error": error,
"error_code": error_code,
}
if retry_after_seconds is not None:
detail["retry_after_seconds"] = round(max(0.0, retry_after_seconds), 2)
return detail
async def fetch_all_balances(
@@ -1534,8 +1589,6 @@ async def fetch_all_balances(
- Total user balance in sats
- Owner balance in sats (wallet - user)
"""
if units is None:
units = ["sat", "msat"]
async def fetch_balance(
session: db.AsyncSession, mint_url: str, unit: str
@@ -1546,18 +1599,36 @@ async def fetch_all_balances(
now = time.monotonic()
failure = _balance_fetch_failures.get(key)
if failure is not None and now < failure[0]:
return _balance_error(mint_url, unit, failure[1])
return _balance_error(
mint_url,
unit,
failure[1],
error_code=failure[2],
retry_after_seconds=failure[0] - now,
)
cooldown = _mint_cooldown_remaining(mint_url)
if cooldown > 0:
error = "Mint cooldown is active"
_balance_fetch_failures[key] = (now + cooldown, error)
return _balance_error(mint_url, unit, error)
error_code = _mint_cooldown_reason(mint_url) or "cooldown"
error = {
"rate_limited": "Mint is rate limited",
"unreachable": "Mint is unreachable",
}.get(error_code, "Mint cooldown is active")
_balance_fetch_failures[key] = (
now + cooldown,
error,
error_code,
)
return _balance_error(
mint_url,
unit,
error,
error_code=error_code,
retry_after_seconds=cooldown,
)
try:
wallet = await get_wallet(
mint_url, unit, retry_on_rate_limit=False
)
wallet = await get_wallet(mint_url, unit, retry_on_rate_limit=False)
proofs = get_proofs_per_mint_and_unit(
wallet, mint_url, unit, not_reserved=True
)
@@ -1570,16 +1641,23 @@ async def fetch_all_balances(
except Exception as error:
connection_failure = is_mint_connection_error(error)
rate_limited = _is_mint_rate_limited(error)
error_code = (
"rate_limited"
if rate_limited
else "unreachable"
if connection_failure
else "mint_error"
)
if connection_failure or rate_limited:
_MintRateGuard.get(mint_url).apply_cooldown(
_BALANCE_FETCH_RETRY_SECONDS
_BALANCE_FETCH_RETRY_SECONDS, reason=error_code
)
retry_delay = max(
_BALANCE_FETCH_RETRY_SECONDS,
_mint_cooldown_remaining(mint_url),
)
retry_at = time.monotonic() + retry_delay
_balance_fetch_failures[key] = (retry_at, str(error))
_balance_fetch_failures[key] = (retry_at, str(error), error_code)
logger.warning(
"Unable to refresh mint balance",
extra={
@@ -1592,7 +1670,13 @@ async def fetch_all_balances(
"retry_seconds": round(retry_delay, 2),
},
)
return _balance_error(mint_url, unit, str(error))
return _balance_error(
mint_url,
unit,
str(error),
error_code=error_code,
retry_after_seconds=retry_delay,
)
_balance_fetch_failures.pop(key, None)
if unit == "sat":
@@ -1616,16 +1700,45 @@ async def fetch_all_balances(
if settings.primary_mint and settings.primary_mint not in mint_urls:
mint_urls.append(settings.primary_mint)
# Create tasks for all mint/unit combinations
async with db.create_session() as session:
tasks = [
fetch_balance(session, mint_url, unit)
for mint_url in mint_urls
for unit in units
]
# Run all tasks concurrently
balance_details = list(await asyncio.gather(*tasks))
async def fetch_mint_balances(mint_url: str) -> list[BalanceDetail]:
mint_units = units
if mint_units is None:
try:
mint_units = await _get_supported_mint_units(mint_url)
except Exception as error:
connection_failure = is_mint_connection_error(error)
rate_limited = _is_mint_rate_limited(error)
if connection_failure:
_MintRateGuard.get(mint_url).apply_cooldown(
_BALANCE_FETCH_RETRY_SECONDS, reason="unreachable"
)
# _mint_operation already records rate-limit cooldowns.
# Fetching the configured unit turns a known cooldown into
# a structured error without another mint request.
mint_units = [settings.primary_mint_unit]
if not connection_failure and not rate_limited:
logger.warning(
"Unable to discover mint units",
extra={
"mint_url": mint_url,
"error": str(error),
"error_type": type(error).__name__,
},
)
return list(
await asyncio.gather(
*(fetch_balance(session, mint_url, unit) for unit in mint_units)
)
)
grouped_details = await asyncio.gather(
*(fetch_mint_balances(mint_url) for mint_url in mint_urls)
)
balance_details = [
detail for mint_details in grouped_details for detail in mint_details
]
# Calculate totals
total_wallet_balance_sats = 0
+106 -6
View File
@@ -14,10 +14,12 @@ def clear_balance_fetch_state() -> Generator[None, None, None]:
wallet._balance_fetch_failures.clear()
wallet._balance_fetch_locks.clear()
wallet._mint_supported_units.clear()
wallet._MintRateGuard._guards.clear()
yield
wallet._balance_fetch_failures.clear()
wallet._balance_fetch_locks.clear()
wallet._mint_supported_units.clear()
wallet._MintRateGuard._guards.clear()
@@ -51,8 +53,9 @@ async def test_fetch_all_balances_falls_back_to_primary_mint() -> None:
"""With empty cashu_mints, balances are still fetched for primary_mint."""
from routstr.core.settings import settings
with patch.object(settings, "cashu_mints", []), patch.object(
settings, "primary_mint", "http://primary:3338"
with (
patch.object(settings, "cashu_mints", []),
patch.object(settings, "primary_mint", "http://primary:3338"),
):
for p in _patches(proof_amount=1000):
p.start()
@@ -67,6 +70,78 @@ async def test_fetch_all_balances_falls_back_to_primary_mint() -> None:
assert total_wallet == 1000
@pytest.mark.asyncio
async def test_fetch_all_balances_uses_units_advertised_by_mint() -> None:
from routstr.core.settings import settings
with (
patch.object(settings, "cashu_mints", ["http://mint:3338"]),
patch.object(settings, "primary_mint", "http://mint:3338"),
patch(
"routstr.wallet._get_supported_mint_units",
AsyncMock(return_value=["sat"]),
) as supported_units,
):
for p in _patches(proof_amount=1000):
p.start()
try:
details, *_ = await fetch_all_balances()
finally:
patch.stopall()
supported_units.assert_awaited_once_with("http://mint:3338")
assert [detail["unit"] for detail in details] == ["sat"]
@pytest.mark.asyncio
async def test_unit_discovery_failure_returns_structured_balance_error() -> None:
from routstr.core.settings import settings
get_wallet = AsyncMock()
with (
patch.object(settings, "cashu_mints", ["http://mint:3338"]),
patch.object(settings, "primary_mint", "http://mint:3338"),
patch(
"routstr.wallet._get_supported_mint_units",
AsyncMock(side_effect=httpx.ConnectError("mint unavailable")),
),
patch("routstr.wallet.get_wallet", get_wallet),
patch("routstr.wallet.db.create_session", _fake_session),
):
details, *_ = await fetch_all_balances()
assert details[0]["unit"] == settings.primary_mint_unit
assert details[0]["error_code"] == "unreachable"
assert details[0]["retry_after_seconds"] > 0
get_wallet.assert_not_awaited()
@pytest.mark.asyncio
async def test_supported_mint_units_come_from_active_keysets() -> None:
from routstr.core.settings import settings
from routstr.wallet import _get_supported_mint_units
sat = MagicMock(active=True)
sat.unit.name = "sat"
msat = MagicMock(active=False)
msat.unit.name = "msat"
usd = MagicMock(active=True)
usd.unit.name = "usd"
wallet = MagicMock()
wallet._get_keysets = AsyncMock(return_value=[usd, msat, sat])
with (
patch.object(settings, "primary_mint_unit", "sat"),
patch("routstr.wallet.get_wallet", AsyncMock(return_value=wallet)),
):
units = await _get_supported_mint_units("http://mint:3338")
cached_units = await _get_supported_mint_units("http://mint:3338")
assert units == ["sat", "usd"]
assert cached_units == units
wallet._get_keysets.assert_awaited_once()
@pytest.mark.asyncio
async def test_fetch_all_balances_backs_off_after_connection_failure() -> None:
from routstr.core.settings import settings
@@ -84,7 +159,10 @@ async def test_fetch_all_balances_backs_off_after_connection_failure() -> None:
second = await fetch_all_balances(units=["sat"])
assert first[0][0]["error"] == "mint unavailable"
assert first[0][0]["error_code"] == "unreachable"
assert first[0][0]["retry_after_seconds"] == 60
assert second[0][0]["error"] == "mint unavailable"
assert second[0][0]["error_code"] == "unreachable"
assert get_wallet.await_count == 1
warning.assert_called_once()
@@ -101,6 +179,25 @@ async def test_fetch_all_balances_backs_off_after_connection_failure() -> None:
assert get_wallet.await_count == 2
@pytest.mark.asyncio
async def test_fetch_all_balances_reports_rate_limit_status() -> None:
from routstr.core.settings import settings
request = httpx.Request("GET", "http://mint:3338/v1/keysets")
response = httpx.Response(429, request=request, headers={"Retry-After": "45"})
error = httpx.HTTPStatusError("rate limited", request=request, response=response)
with (
patch.object(settings, "cashu_mints", ["http://mint:3338"]),
patch.object(settings, "primary_mint", "http://mint:3338"),
patch("routstr.wallet.get_wallet", AsyncMock(side_effect=error)),
patch("routstr.wallet.db.create_session", _fake_session),
):
details, *_ = await fetch_all_balances(units=["sat"])
assert details[0]["error_code"] == "rate_limited"
assert details[0]["retry_after_seconds"] == 60
@pytest.mark.asyncio
async def test_balance_failure_applies_mint_cooldown_to_other_units() -> None:
from routstr.core.settings import settings
@@ -123,7 +220,9 @@ async def test_balance_failure_applies_mint_cooldown_to_other_units() -> None:
assert warning.call_count == 1
assert cooldown == 60
assert details[0]["error"] == "mint unavailable"
assert details[1]["error"] == "Mint cooldown is active"
assert details[0]["error_code"] == "unreachable"
assert details[1]["error"] == "Mint is unreachable"
assert details[1]["error_code"] == "unreachable"
@pytest.mark.asyncio
@@ -131,9 +230,10 @@ async def test_fetch_all_balances_no_duplicate_primary_mint() -> None:
"""primary_mint already in cashu_mints is not inspected twice."""
from routstr.core.settings import settings
with patch.object(
settings, "cashu_mints", ["http://primary:3338"]
), patch.object(settings, "primary_mint", "http://primary:3338"):
with (
patch.object(settings, "cashu_mints", ["http://primary:3338"]),
patch.object(settings, "primary_mint", "http://primary:3338"),
):
for p in _patches(proof_amount=1000):
p.start()
try:
+1 -3
View File
@@ -1234,9 +1234,7 @@ def test_rate_limited_mint_is_classified_as_unreachable() -> None:
request = httpx.Request("POST", "http://mint:3338/v1/swap")
response = httpx.Response(429, request=request)
error = httpx.HTTPStatusError(
"rate limited", request=request, response=response
)
error = httpx.HTTPStatusError("rate limited", request=request, response=response)
assert classify_redemption_error(error) == (
"mint_unreachable",
+27 -4
View File
@@ -105,6 +105,23 @@ export function DetailedWalletBalance({
const formatMintLabel = (detail: BalanceDetail) =>
`${detail.mint_url.replace('https://', '').replace('http://', '')} • ${detail.unit.toUpperCase()}`;
const formatBalanceError = (detail: BalanceDetail) => {
const labels: Record<string, string> = {
rate_limited: 'rate limited',
unreachable: 'unreachable',
cooldown: 'cooling down',
mint_error: 'mint error',
};
const label =
(detail.error_code ? labels[detail.error_code] : undefined) ??
detail.error ??
'error';
const retryAfter = detail.retry_after_seconds;
return retryAfter && retryAfter > 0
? `${label} (retry in ${Math.ceil(retryAfter)}s)`
: label;
};
return (
<>
<Card>
@@ -262,9 +279,12 @@ export function DetailedWalletBalance({
<TableCell className='max-w-md font-mono text-xs break-all whitespace-normal'>
{formatMintLabel(detail)}
</TableCell>
<TableCell className='text-right font-mono'>
<TableCell
className='text-right font-mono'
title={detail.error}
>
{detail.error
? 'error'
? formatBalanceError(detail)
: formatAmount(walletMsat)}
</TableCell>
<TableCell className='text-right font-mono'>
@@ -306,9 +326,12 @@ export function DetailedWalletBalance({
<p className='text-muted-foreground text-xs'>
Wallet
</p>
<p className='font-mono text-sm'>
<p
className='font-mono text-sm'
title={detail.error}
>
{detail.error
? 'error'
? formatBalanceError(detail)
: formatAmount(walletMsat)}
</p>
</div>
+2
View File
@@ -36,6 +36,8 @@ export interface BalanceDetail {
user_balance: number;
owner_balance: number;
error?: string;
error_code?: 'rate_limited' | 'unreachable' | 'cooldown' | 'mint_error';
retry_after_seconds?: number;
}
export interface WithdrawResponse {