mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 20:28:23 +00:00
@@ -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:
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user