Merge pull request #664 from Routstr/test-v2-cashu

Test v2 cashu
This commit is contained in:
9qeklajc
2026-08-16 17:38:49 +02:00
committed by GitHub
5 changed files with 177 additions and 8 deletions
+7 -7
View File
@@ -52,13 +52,13 @@ async def http_exception_handler(request: Request, exc: Exception) -> JSONRespon
},
)
return JSONResponse(
status_code=status_code,
content={
"detail": detail,
"request_id": request_id,
},
)
if isinstance(detail, dict) and "error" in detail:
content = {"detail": detail, **detail}
else:
content = {"detail": detail}
content["request_id"] = request_id
return JSONResponse(status_code=status_code, content=content)
async def general_exception_handler(request: Request, exc: Exception) -> JSONResponse:
+12
View File
@@ -31,6 +31,18 @@ _MINT_RATE_LIMIT_MAX_COOLDOWN_SECONDS = 7 * 60 * 60
_fail_fast_depth: ContextVar[int] = ContextVar("mint_fail_fast_depth", default=0)
class MintError(Exception):
"""Structured error response returned by a Cashu mint."""
def __init__(self, detail: Any, code: Any | None = None):
self.detail = detail
self.code = code
message = f"Mint Error: {detail}"
if code is not None:
message += f" (Code: {code})"
super().__init__(message)
class MintRateLimitedError(httpx.HTTPStatusError):
"""Typed boundary error preserving a Cashu mint's HTTP 429 response."""
+15 -1
View File
@@ -1,5 +1,6 @@
import asyncio
import fcntl
import json
import os
import re
import time
@@ -24,6 +25,7 @@ from .core.settings import settings
from .mint import (
MINT_TRANSPORT_COOLDOWN_SECONDS,
MINT_TRANSPORT_EXCEPTIONS,
MintError,
MintRateGuard,
MintRateLimitedError,
fail_fast_mint_operations,
@@ -130,8 +132,21 @@ class Wallet(_CashuWallet):
request=resp.request,
response=resp,
)
try:
response_data = resp.json()
except json.JSONDecodeError:
response_data = None
if isinstance(response_data, dict) and "detail" in response_data:
raise MintError(response_data["detail"], response_data.get("code"))
_CashuWallet.raise_on_error_request(resp)
async def load_mint(
self, keyset_id: str = "", force_old_keysets: bool = False
) -> None:
await self.load_mint_keysets(force_old_keysets)
await self.activate_keyset(keyset_id)
await self.load_mint_info(reload=True)
class MintConnectionError(Exception):
"""The mint could not be reached (network transport failure).
@@ -307,7 +322,6 @@ async def _load_and_resolve_token_proofs(
``TokenV4.proofs`` rebuilds its list on each access, so callers must reuse
this list after Cashu expands short keyset IDs in place.
"""
# Cashu's load_mint() suppresses failures, which can leave cached keysets stale.
try:
await run_mint_operation(
lambda: wallet.load_mint_keysets(),
+75
View File
@@ -0,0 +1,75 @@
import json
import pytest
from fastapi import HTTPException
from starlette.requests import Request
from routstr.core.exceptions import http_exception_handler
def _request() -> Request:
request = Request(
{
"type": "http",
"method": "POST",
"path": "/v1/chat/completions",
"headers": [],
"query_string": b"",
"scheme": "http",
"server": ("test", 80),
"client": ("test", 123),
}
)
request.state.request_id = "req-123"
return request
@pytest.mark.asyncio
async def test_structured_http_error_uses_standard_error_envelope() -> None:
request = _request()
error = {
"message": "Cashu mint is unreachable",
"type": "mint_unreachable",
"code": "cashu_mint_unreachable",
"details": {"mint": "https://mint.example"},
}
response = await http_exception_handler(
request,
HTTPException(status_code=503, detail={"error": error}),
)
assert response.status_code == 503
assert json.loads(response.body) == {
"detail": {"error": error},
"error": error,
"request_id": "req-123",
}
@pytest.mark.asyncio
async def test_string_error_uses_standard_error_envelope() -> None:
error = "max_tokens must be an integer"
response = await http_exception_handler(
_request(),
HTTPException(status_code=400, detail={"error": error}),
)
assert json.loads(response.body) == {
"detail": {"error": error},
"error": error,
"request_id": "req-123",
}
@pytest.mark.asyncio
async def test_plain_http_error_keeps_detail_envelope() -> None:
response = await http_exception_handler(
_request(),
HTTPException(status_code=404, detail="Not found"),
)
assert json.loads(response.body) == {
"detail": "Not found",
"request_id": "req-123",
}
+68
View File
@@ -2500,6 +2500,24 @@ async def test_swap_falls_back_when_primary_wallet_cannot_load() -> None:
assert "cashu_swap_completed" in events
def test_raise_on_error_request_identifies_cashu_mint_error() -> None:
from routstr.mint import MintError
from routstr.wallet import Wallet
response = httpx.Response(
400,
request=httpx.Request("POST", "https://mint.example/v1/swap"),
json={"detail": "Token already spent.", "code": 11001},
)
with pytest.raises(MintError) as captured:
Wallet.raise_on_error_request(response)
assert captured.value.detail == "Token already spent."
assert captured.value.code == 11001
assert str(captured.value) == "Mint Error: Token already spent. (Code: 11001)"
@pytest.mark.asyncio
async def test_lightning_mint_fallback_on_cashu_json_429() -> None:
"""The real Cashu JSON-error adapter preserves 429 for fallback."""
@@ -2944,3 +2962,53 @@ async def test_payout_reloads_wallet_snapshot_under_guard() -> None:
mock_get_wallet.assert_awaited_once_with(
"https://mint.example.com", "sat", force_reload=True
)
@pytest.mark.asyncio
async def test_load_mint_propagates_rate_limit() -> None:
from routstr.mint import MintRateLimitedError
from routstr.wallet import Wallet
wallet = Wallet.__new__(Wallet)
error = MintRateLimitedError(
"Cashu mint rate limited",
request=httpx.Request("GET", "https://mint.example/v1/keysets"),
response=httpx.Response(429),
)
with (
patch.object(wallet, "load_mint_keysets", new=AsyncMock(side_effect=error)),
pytest.raises(MintRateLimitedError),
):
await wallet.load_mint()
@pytest.mark.asyncio
async def test_load_mint_propagates_connection_error() -> None:
from routstr.wallet import Wallet
wallet = Wallet.__new__(Wallet)
error = httpx.ConnectError("mint unavailable")
with (
patch.object(wallet, "load_mint_keysets", new=AsyncMock(side_effect=error)),
pytest.raises(httpx.ConnectError) as captured,
):
await wallet.load_mint()
assert captured.value is error
@pytest.mark.asyncio
async def test_load_mint_runs_keysets_activation_and_info() -> None:
from routstr.wallet import Wallet
wallet = Wallet.__new__(Wallet)
with (
patch.object(wallet, "load_mint_keysets", new=AsyncMock()) as load_keysets,
patch.object(wallet, "activate_keyset", new=AsyncMock()) as activate,
patch.object(wallet, "load_mint_info", new=AsyncMock()) as load_info,
):
await wallet.load_mint(keyset_id="abc")
load_keysets.assert_awaited_once_with(False)
activate.assert_awaited_once_with("abc")
load_info.assert_awaited_once_with(reload=True)