diff --git a/routstr/core/exceptions.py b/routstr/core/exceptions.py index 8dcda2a5..9e4f2ae6 100644 --- a/routstr/core/exceptions.py +++ b/routstr/core/exceptions.py @@ -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: diff --git a/routstr/mint.py b/routstr/mint.py index c0f0ea3c..64632b9e 100644 --- a/routstr/mint.py +++ b/routstr/mint.py @@ -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.""" diff --git a/routstr/wallet.py b/routstr/wallet.py index be7cd76e..383c79c8 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -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(), diff --git a/tests/unit/test_core_exceptions.py b/tests/unit/test_core_exceptions.py new file mode 100644 index 00000000..3d9469df --- /dev/null +++ b/tests/unit/test_core_exceptions.py @@ -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", + } diff --git a/tests/unit/test_wallet.py b/tests/unit/test_wallet.py index a2347b14..7c5de1fe 100644 --- a/tests/unit/test_wallet.py +++ b/tests/unit/test_wallet.py @@ -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)