diff --git a/docs/api/errors.md b/docs/api/errors.md index dc970cb1..e6a41deb 100644 --- a/docs/api/errors.md +++ b/docs/api/errors.md @@ -113,42 +113,109 @@ All errors follow a consistent JSON structure: **Status:** 402 **Resolution:** Top up API key balance -#### Invalid Token +### Cashu Token Redemption Errors + +These errors are returned when a Cashu token you pay with cannot be redeemed. +They apply to every endpoint that accepts a token: + +- **Per-request payment** via the `X-Cashu` header (chat completions + Responses API). +- **API key top-up** via `POST /v1/wallet/topup`. +- **Minting an API key** from a token sent in `Authorization: Bearer `. + +All three share one classifier, so the same failure yields the same HTTP status +and sanitized message everywhere. Structured error envelopes (`X-Cashu` and +`Authorization: Bearer `) also expose the same `type` and `code` — +branch on `type` (or `code` for finer granularity). `POST /v1/wallet/topup` +keeps its existing plain-string `detail` envelope, so branch on status there. + +| `type` | Status | `code` | Retryable | Meaning | +|--------|--------|--------|-----------|---------| +| `token_already_spent` | 400 | `cashu_token_already_spent` | No | The token was already redeemed. | +| `invalid_token` | 400 | `invalid_cashu_token` | No | The token is malformed or cannot be decoded. | +| `mint_error` | 422 | `cashu_token_swap_fees_exceed_amount` | No | Token value is too small to cover the mint's swap/melt fees. | +| `mint_error` | 422 | `cashu_foreign_mint_swap_failed` | No | Swapping the token from a foreign mint to the primary mint failed. | +| `mint_unreachable` | 503 | `cashu_mint_unreachable` | **Yes** | The mint could not be reached (DNS failure, refused/reset connection, timeout). The token is fine — retry once the mint recovers. | +| `cashu_error` | 400 | `cashu_token_redemption_failed` | No | The token could not be redeemed for another expected reason. | +| `cashu_error` | 400 | `cashu_token_zero_value` | No | The token redeemed to zero (empty/dust token, or value fully consumed by fees). | +| `token_consumed` | 500 | `cashu_token_consumed` | No | The token was **spent** (melted/redeemed) but crediting it then failed. Do not retry — the token is gone; contact support to reconcile. | +| `api_error` | 500 | `internal_error` | Maybe | Unexpected server-side fault during redemption. | + +!!! important "Retry only `mint_unreachable`" + Only `mint_unreachable` (503) means the same token will work again later — + everything else is a permanent property of the token and must not be + blindly retried. Use exponential backoff for the 503. In particular, a + `token_consumed` 500 means the mint already spent the token, so a retry + would fail as `token_already_spent`. + +#### Mint Unreachable (retryable) ```json { "error": { - "type": "payment_error", - "message": "Invalid Cashu token", - "code": "invalid_token", - "details": { - "reason": "Token already spent" - } + "type": "mint_unreachable", + "message": "Cashu mint is unreachable", + "code": "cashu_mint_unreachable" } } ``` -**Status:** 400 -**Resolution:** Use a valid, unspent token +**Status:** 503 -#### Mint Unavailable +**Resolution:** The token is valid — the mint is temporarily down. Retry with +backoff, or pay with a token from a different mint. + +#### Token Already Spent ```json { "error": { - "type": "payment_error", - "message": "Cannot connect to Cashu mint", - "code": "mint_unavailable", - "details": { - "mint_url": "https://mint.example.com", - "retry_after": 60 - } + "type": "token_already_spent", + "message": "Cashu token already spent", + "code": "cashu_token_already_spent" } } ``` -**Status:** 503 -**Resolution:** Try again later or use different mint +**Status:** 400 + +**Resolution:** Use a fresh, unspent token. Do not retry with the same token. + +#### Response envelope differs by endpoint + +The `error` object above is identical everywhere, but the surrounding envelope +depends on how you paid: + +- **`X-Cashu` header payments** (chat + Responses API) return the object at the + top level, alongside a `request_id`: + + ```json + { + "error": { "type": "mint_unreachable", "message": "Cashu mint is unreachable", "code": "cashu_mint_unreachable" }, + "request_id": "req-abc123" + } + ``` + + The original token is echoed back in the `X-Cashu` **response header only when + it is still spendable** (e.g. `mint_unreachable`, `invalid_cashu_token`, fee + errors) so you can recover/retry it. It is **not** echoed for spent/consumed + tokens (`cashu_token_already_spent`, `cashu_token_consumed`, + `cashu_token_zero_value`, `internal_error`) — retrying those can never succeed. + +- **`Authorization: Bearer `** (API key minting) wraps it in + FastAPI's `detail` field: + + ```json + { "detail": { "error": { "type": "mint_unreachable", "message": "Cashu mint is unreachable", "code": "cashu_mint_unreachable" } } } + ``` + +- **`POST /v1/wallet/topup`** returns a plain string message under `detail` — + it carries the shared HTTP **status** and **message** (e.g. `503` for an + unreachable mint) but not the structured `type`/`code`, so branch on the + status code here: + + ```json + { "detail": "Cashu mint is unreachable" } + ``` ### Validation Errors @@ -347,7 +414,7 @@ class ErrorHandler: 'rate_limit', 'upstream_timeout', 'model_overloaded', - 'mint_unavailable' + 'cashu_mint_unreachable' } # Errors requiring user action diff --git a/routstr/auth.py b/routstr/auth.py index 60678e7d..23ad6920 100644 --- a/routstr/auth.py +++ b/routstr/auth.py @@ -20,7 +20,11 @@ from .payment.cost_calculation import ( MaxCostData, calculate_cost, ) -from .wallet import credit_balance, deserialize_token_from_string +from .wallet import ( + classify_redemption_error, + credit_balance, + deserialize_token_from_string, +) logger = get_logger(__name__) payments_logger = get_logger("routstr.payments") @@ -81,56 +85,11 @@ async def check_and_reset_limit(key: ApiKey, session: AsyncSession) -> bool: def redemption_error_to_http_exception(error: Exception) -> HTTPException: """Map a Cashu token redemption failure to a sanitized client-facing error. - Uses the same failure taxonomy as the X-Cashu redemption path - (``token_already_spent``/``invalid_token``/``mint_error``/``cashu_error``, - see ``create_error_response`` in ``payment/helpers.py``) so both paths that - redeem a token agree on the classification (carried in ``type``) and status. - Expected wallet errors (``ValueError``) and known mint failures map to a - 4xx with a stable message; anything else is an internal fault and maps to a - generic 500. Raw error text never reaches the client — it stays in logs. + Thin wrapper over the shared :func:`classify_redemption_error` so the bearer + path stays identical to the X-Cashu and top-up paths. """ - lowered = str(error).lower() - # (type, status, message) — type mirrors the X-Cashu taxonomy strings. - if "already spent" in lowered: - error_type, status_code, message = ( - "token_already_spent", - 400, - "Cashu token already spent", - ) - elif ( - "insufficient" in lowered - or "melt fee" in lowered - or "exceed token amount" in lowered - or "estimate fees" in lowered - ): - error_type, status_code, message = ( - "mint_error", - 422, - "Token value is too small to cover swap fees", - ) - elif "failed to melt" in lowered: - error_type, status_code, message = ( - "mint_error", - 422, - "Failed to swap token from foreign mint", - ) - elif ("invalid" in lowered or "decode" in lowered) and "token" in lowered: - # Anchor the broad buckets to "token" so internal faults whose text - # merely contains "invalid"/"decode" (e.g. "invalid literal", - # SQLAlchemy "Invalid …") fall through to the generic 500 below - # instead of masquerading as a token error. - error_type, status_code, message = ( - "invalid_token", - 400, - "Invalid Cashu token", - ) - elif isinstance(error, ValueError): - error_type, status_code, message = ( - "cashu_error", - 400, - "Failed to redeem Cashu token", - ) - else: + classified = classify_redemption_error(error) + if classified is None: return HTTPException( status_code=500, detail={ @@ -141,13 +100,14 @@ def redemption_error_to_http_exception(error: Exception) -> HTTPException: } }, ) + error_type, status_code, message, error_code = classified return HTTPException( status_code=status_code, detail={ "error": { "message": message, "type": error_type, - "code": status_code, + "code": error_code, } }, ) @@ -291,7 +251,17 @@ async def validate_bearer_key( try: hashed_key = hashlib.sha256(bearer_key.encode()).hexdigest() - token_obj = deserialize_token_from_string(bearer_key) + try: + token_obj = deserialize_token_from_string(bearer_key) + except Exception as decode_error: + # A malformed token is a bad token (400 invalid_cashu_token via + # the shared taxonomy), not an auth failure (401) — otherwise it + # would fall through to the generic "Invalid API key" handler. + raise redemption_error_to_http_exception( + ValueError( + f"Invalid Cashu token: could not decode token ({decode_error})" + ) + ) from decode_error logger.debug( "Generated token hash", extra={"hash_preview": hashed_key[:16] + "..."} ) @@ -431,7 +401,7 @@ async def validate_bearer_key( "error": { "message": "Failed to redeem Cashu token: token yielded no value", "type": "cashu_error", - "code": 400, + "code": "cashu_token_zero_value", } }, ) diff --git a/routstr/balance.py b/routstr/balance.py index 2f24cb8c..184c445a 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -20,7 +20,13 @@ from .core.db import ( from .core.logging import get_logger from .core.settings import settings from .lightning import lightning_router -from .wallet import credit_balance, recieve_token, send_to_lnurl, send_token +from .wallet import ( + classify_redemption_error, + credit_balance, + recieve_token, + send_to_lnurl, + send_token, +) router = APIRouter() balance_router = APIRouter(prefix="/v1/balance") @@ -156,30 +162,18 @@ async def topup_wallet_endpoint( raise HTTPException(status_code=400, detail="Invalid token format") try: amount_msats = await credit_balance(cashu_token, billing_key, session) - except ValueError as e: - error_msg = str(e) - if "already spent" in error_msg.lower(): - raise HTTPException(status_code=400, detail="Token already spent") - elif "invalid" in error_msg.lower() or "decode" in error_msg.lower(): - raise HTTPException(status_code=400, detail="Invalid token format") - elif "insufficient" in error_msg.lower() or "melt fee" in error_msg.lower(): - raise HTTPException( - status_code=400, - detail=f"Token value is too small to cover swap fees. {error_msg}", - ) - elif "failed to melt" in error_msg.lower(): - raise HTTPException( - status_code=400, - detail=f"Failed to swap foreign mint token. {error_msg}", - ) - else: - raise HTTPException(status_code=400, detail=f"Failed to redeem token: {error_msg}") except Exception as e: - logger.error( - "topup_wallet_endpoint: unhandled error", - extra={"error": str(e), "error_type": type(e).__name__}, - ) - raise HTTPException(status_code=500, detail="Internal server error") + # Shared taxonomy so top-up matches the bearer/X-Cashu paths (503 for an + # unreachable mint, 422 for fee/swap failures, 400 for token faults). + classified = classify_redemption_error(e) + if classified is None: + logger.error( + "topup_wallet_endpoint: unhandled error", + extra={"error": str(e), "error_type": type(e).__name__}, + ) + raise HTTPException(status_code=500, detail="Internal server error") + _type, status_code, message, _code = classified + raise HTTPException(status_code=status_code, detail=message) return {"msats": amount_msats} diff --git a/routstr/upstream/base.py b/routstr/upstream/base.py index c3d9c5d6..f8ea2d4a 100644 --- a/routstr/upstream/base.py +++ b/routstr/upstream/base.py @@ -40,7 +40,12 @@ from ..payment.models import ( list_models, ) from ..payment.price import sats_usd_price -from ..wallet import recieve_token, send_token +from ..wallet import ( + SPENT_TOKEN_CODES, + classify_redemption_error, + recieve_token, + send_token, +) from . import messages_dispatch from .cache_breakpoints import ( inject_anthropic_cache_breakpoints, @@ -3981,9 +3986,19 @@ class BaseUpstreamProvider: }, ) + redeemed = False try: headers = dict(request.headers) amount, unit, mint = await recieve_token(x_cashu_token) + # Reject a zero/negative redemption (empty/dust token, or a value + # fully consumed by fees) before marking the token redeemed, so it + # classifies as cashu_token_zero_value like the bearer/top-up paths + # rather than being forwarded as a free request. + if amount <= 0: + raise ValueError( + f"Redeemed token amount must be positive, got {amount} {unit}" + ) + redeemed = True headers = self.prepare_headers(dict(request.headers)) request_id = getattr(request.state, "request_id", None) @@ -4027,40 +4042,37 @@ class BaseUpstreamProvider: }, ) - # Use same error handling as regular X-Cashu - if "already spent" in error_message.lower(): + # Post-redemption the token is spent; a forwarding failure must not + # be reported as a retryable redemption error (see handle_x_cashu). + if redeemed: return create_error_response( - "token_already_spent", - "The provided CASHU token has already been spent", - 400, + "upstream_error", + "Payment succeeded but the upstream request failed", + 502, request=request, - token=x_cashu_token, + code="upstream_request_failed", ) - if "invalid token" in error_message.lower(): + classified = classify_redemption_error(e) + if classified is None: return create_error_response( - "invalid_token", - "The provided CASHU token is invalid", - 400, + "api_error", + "Internal error during token redemption", + 500, request=request, - token=x_cashu_token, + code="internal_error", ) - - if "mint error" in error_message.lower(): - return create_error_response( - "mint_error", - "CASHU mint error while processing token", - 422, - request=request, - token=x_cashu_token, - ) - + error_type, status_code, message, error_code = classified + # Echo the token back only when it is still spendable, so clients + # can recover it; a spent/consumed token is never re-offered. + echo_token = None if error_code in SPENT_TOKEN_CODES else x_cashu_token return create_error_response( - "cashu_error", - "CASHU token processing failed", - 400, + error_type, + message, + status_code, request=request, - token=x_cashu_token, + token=echo_token, + code=error_code, ) async def forward_x_cashu_responses_request( @@ -4650,9 +4662,19 @@ class BaseUpstreamProvider: }, ) + redeemed = False try: headers = dict(request.headers) amount, unit, mint = await recieve_token(x_cashu_token) + # Reject a zero/negative redemption (empty/dust token, or a value + # fully consumed by fees) before marking the token redeemed, so it + # classifies as cashu_token_zero_value like the bearer/top-up paths + # rather than being forwarded as a free request. + if amount <= 0: + raise ValueError( + f"Redeemed token amount must be positive, got {amount} {unit}" + ) + redeemed = True headers = self.prepare_headers(dict(request.headers)) request_id = getattr(request.state, "request_id", None) @@ -4696,39 +4718,38 @@ class BaseUpstreamProvider: }, ) - if "already spent" in error_message.lower(): + # Once redeemed the token is spent, so a later forwarding failure + # must not surface as a retryable mint_unreachable (spent-token retry + # bait). Redemption classification only applies while not redeemed. + if redeemed: return create_error_response( - "token_already_spent", - "The provided CASHU token has already been spent", - 400, + "upstream_error", + "Payment succeeded but the upstream request failed", + 502, request=request, - token=x_cashu_token, + code="upstream_request_failed", ) - if "invalid token" in error_message.lower(): + classified = classify_redemption_error(e) + if classified is None: return create_error_response( - "invalid_token", - "The provided CASHU token is invalid", - 400, + "api_error", + "Internal error during token redemption", + 500, request=request, - token=x_cashu_token, + code="internal_error", ) - - if "mint error" in error_message.lower(): - return create_error_response( - "mint_error", - "CASHU mint error while processing token", - 422, - request=request, - token=x_cashu_token, - ) - + error_type, status_code, message, error_code = classified + # Echo the token back only when it is still spendable, so clients + # can recover it; a spent/consumed token is never re-offered. + echo_token = None if error_code in SPENT_TOKEN_CODES else x_cashu_token return create_error_response( - "cashu_error", - "CASHU token processing failed", - 400, + error_type, + message, + status_code, request=request, - token=x_cashu_token, + token=echo_token, + code=error_code, ) def _apply_provider_fee_to_model(self, model: Model) -> Model: diff --git a/routstr/wallet.py b/routstr/wallet.py index 4ecb99f2..ccf7877f 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -1,9 +1,11 @@ import asyncio import re +import socket import time import typing from typing import TypedDict +import httpx from cashu.core.base import Proof, Token from cashu.core.mint_info import MintInfo as _CashuMintInfo from cashu.wallet.helpers import deserialize_token_from_string @@ -31,6 +33,149 @@ _CashuMintInfo.model_rebuild(force=True) logger = get_logger(__name__) +class MintConnectionError(Exception): + """The mint could not be reached (network transport failure). + + Maps to a 503, not a 4xx: the token is fine, the mint is just unavailable. + """ + + +class TokenConsumedError(Exception): + """A failure that happened AFTER the token's proofs were spent (melt + succeeded, or redemption already returned) — e.g. minting on the primary + mint or the DB credit then failed. + + Non-retryable: the same token will not work again. Seals the cause chain so + a transport error underneath is never re-surfaced as a retryable + mint_unreachable. + """ + + +# httpx base classes cover their subclasses. HTTPStatusError is excluded on +# purpose — that means the mint answered, just with an error status. +_TRANSPORT_EXC_TYPES: tuple[type[BaseException], ...] = ( + httpx.NetworkError, + httpx.TimeoutException, + ConnectionError, # refused/reset/aborted + socket.gaierror, # DNS failure + asyncio.TimeoutError, +) + + +def is_mint_connection_error(error: BaseException) -> bool: + """True if ``error`` (or anything in its cause/context chain) is a mint + transport failure. Walks the chain because some sites re-raise transport + errors wrapped in ValueError/MintConnectionError; matches on TYPE, not text. + """ + seen: set[int] = set() + current: BaseException | None = error + while current is not None and id(current) not in seen: + seen.add(id(current)) + if isinstance(current, TokenConsumedError): + # Sealed: the token was already spent, so whatever transport error + # sits underneath must not make this look retryable. + return False + if isinstance(current, MintConnectionError): + return True + if isinstance(current, _TRANSPORT_EXC_TYPES): + return True + current = current.__cause__ or current.__context__ + return False + + +# Redemption ``code`` values whose token is spent/consumed/unusable — the +# X-Cashu path must NOT echo the original token for these (echoing invites a +# retry with a token that can never succeed again). +SPENT_TOKEN_CODES: frozenset[str] = frozenset( + { + "cashu_token_already_spent", + "cashu_token_consumed", + "cashu_token_zero_value", + "internal_error", + } +) + + +def classify_redemption_error( + error: Exception, +) -> tuple[str, int, str, str] | None: + """Map a token-redemption failure to ``(type, status, message, code)``. + + Single source of truth for every endpoint that redeems a token (bearer, + X-Cashu, top-up) so the same failure yields the same taxonomy everywhere. + ``type`` and ``code`` are stable client contract; ``message`` is sanitized + (raw error text stays in logs). Returns None for an unclassified internal + fault — the caller emits a generic 500. + """ + if isinstance(error, TokenConsumedError): + return ( + "token_consumed", + 500, + "Token was redeemed but could not be credited; do not retry", + "cashu_token_consumed", + ) + if is_mint_connection_error(error): + return ( + "mint_unreachable", + 503, + "Cashu mint is unreachable", + "cashu_mint_unreachable", + ) + lowered = str(error).lower() + if "already spent" in lowered: + return ( + "token_already_spent", + 400, + "Cashu token already spent", + "cashu_token_already_spent", + ) + if ( + "insufficient" in lowered + or "melt fee" in lowered + or "exceed token amount" in lowered + or "estimate fees" in lowered + ): + return ( + "mint_error", + 422, + "Token value is too small to cover swap fees", + "cashu_token_swap_fees_exceed_amount", + ) + if "failed to melt" in lowered: + return ( + "mint_error", + 422, + "Failed to swap token from foreign mint", + "cashu_foreign_mint_swap_failed", + ) + if ("invalid" in lowered or "decode" in lowered) and "token" in lowered: + # Anchored to "token" so internal faults whose text merely contains + # "invalid"/"decode" fall through to the 500 branch, not a token error. + return ( + "invalid_token", + 400, + "Invalid Cashu token", + "invalid_cashu_token", + ) + if "must be positive" in lowered or "yielded no value" in lowered: + # Redeemed to <= 0 (empty/dust token, or value fully consumed by fees). + # Consumed, so non-retryable, but its own code — not the generic bucket. + return ( + "cashu_error", + 400, + "Failed to redeem Cashu token: token yielded no value", + "cashu_token_zero_value", + ) + if isinstance(error, ValueError): + return ( + "cashu_error", + 400, + "Failed to redeem Cashu token", + "cashu_token_redemption_failed", + ) + return None + + async def get_balance(unit: str) -> int: wallet = await get_wallet(settings.primary_mint, unit) return wallet.available_balance.amount @@ -258,6 +403,8 @@ async def _calculate_swap_amount( "swap_to_primary_mint: fee estimation failed", extra={"error": str(e)}, ) + if is_mint_connection_error(e): + raise MintConnectionError("Cashu mint is unreachable") from e raise ValueError(f"Failed to estimate fees: {e}") from e @@ -383,6 +530,13 @@ async def swap_to_primary_mint( quote_id=melt_quote.quote, ) except Exception as e: + # A down mint won't fix itself by retrying with a smaller amount. + if is_mint_connection_error(e): + logger.error( + "swap_to_primary_mint: melt failed — mint unreachable", + extra={"error": str(e), "foreign_mint": token_obj.mint}, + ) + raise MintConnectionError("Cashu mint is unreachable") from e shortfall = _melt_insufficient_shortfall(e) recomputed = 0 if shortfall is not None: @@ -458,21 +612,21 @@ async def swap_to_primary_mint( # Recovery scan ran but did NOT restore the orphaned proofs # (mint reports them as spent — they're stuck). Refuse to # credit the API key balance for proofs we don't actually hold. - raise ValueError( + raise TokenConsumedError( f"Swap recovery failed: mint signed outputs but proofs are " f"unrecoverable (mint reports them spent). " f"Expected {minted_amount}, recovered {balance_gained}. " f"Local wallet DB ('.wallet/') state is corrupted — " f"the counter for keyset is stuck at a bad index range." ) - except ValueError: + except TokenConsumedError: raise except Exception as recovery_err: logger.error( "swap_to_primary_mint: recovery failed", extra={"error": str(recovery_err)}, ) - raise ValueError( + raise TokenConsumedError( f"Mint on primary failed and recovery unsuccessful: {e}" ) from e else: @@ -485,7 +639,10 @@ async def swap_to_primary_mint( "mint_quote_id": mint_quote.quote, }, ) - raise + # Foreign proofs already melted (spent) — non-retryable. + raise TokenConsumedError( + "Mint on primary failed after successful melt" + ) from e logger.info( "swap_to_primary_mint: completed successfully", @@ -543,19 +700,33 @@ async def credit_balance( extra={"old_balance": key.balance, "credit_amount": amount}, ) - # Use atomic SQL UPDATE to prevent race conditions during concurrent topups - stmt = ( - update(db.ApiKey) - .where(col(db.ApiKey.hashed_key) == key.hashed_key) - .values(balance=(db.ApiKey.balance) + amount) - ) - result = await session.exec(stmt) # type: ignore[call-overload] - # If pruning removed this key after redemption, do not commit a no-op - # balance update and pretend the top-up succeeded. - if (getattr(result, "rowcount", 0) or 0) == 0: - raise ValueError("API key disappeared before credit could be recorded") - await session.commit() - await session.refresh(key) + # The token is already redeemed (spent) here, so any crediting failure + # is post-redemption and non-retryable — surface it as TokenConsumedError + # (a key that vanished mid-flight, or an unexpected DB fault), never a + # retryable/token-error taxonomy. + try: + # Atomic UPDATE to prevent race conditions during concurrent topups. + stmt = ( + update(db.ApiKey) + .where(col(db.ApiKey.hashed_key) == key.hashed_key) + .values(balance=(db.ApiKey.balance) + amount) + ) + result = await session.exec(stmt) # type: ignore[call-overload] + # If pruning removed this key after redemption, do not commit a no-op + # balance update and pretend the top-up succeeded. + if (getattr(result, "rowcount", 0) or 0) == 0: + raise TokenConsumedError( + "Token redeemed but the API key disappeared before the " + "credit could be recorded" + ) + await session.commit() + await session.refresh(key) + except TokenConsumedError: + raise + except Exception as db_error: + raise TokenConsumedError( + "Token redeemed but crediting the balance failed" + ) from db_error logger.info( "credit_balance: Balance updated successfully", diff --git a/tests/integration/test_swap_fee_retry.py b/tests/integration/test_swap_fee_retry.py index bf1537b0..d2326a89 100644 --- a/tests/integration/test_swap_fee_retry.py +++ b/tests/integration/test_swap_fee_retry.py @@ -174,12 +174,12 @@ async def test_topup_retries_when_quote_fee_exceeds_estimate( @pytest.mark.integration @pytest.mark.asyncio -async def test_topup_returns_400_when_retries_exhausted( +async def test_topup_returns_422_when_retries_exhausted( authenticated_client: AsyncClient, ) -> None: """A mint that escalates fee_reserve on every re-quote (1 → 10 → 25 → 50) - exhausts the retry budget: clean 400 with an actionable message, melt never - executed.""" + exhausts the retry budget: clean 422 mint_error/too-small taxonomy, melt + never executed.""" mock_token, token_wallet, primary_wallet = _make_swap_mocks( 1000, fee_reserves=[1, 10, 25, 50] ) @@ -188,7 +188,7 @@ async def test_topup_returns_400_when_retries_exhausted( authenticated_client, mock_token, token_wallet, primary_wallet ) - assert response.status_code == 400 + assert response.status_code == 422 assert "too small to cover swap fees" in response.json()["detail"] assert token_wallet.melt_quote.call_count == 4 # estimation + 3 attempts token_wallet.melt.assert_not_called() diff --git a/tests/integration/test_wallet_authentication.py b/tests/integration/test_wallet_authentication.py index 1b9d5979..9dc19947 100644 --- a/tests/integration/test_wallet_authentication.py +++ b/tests/integration/test_wallet_authentication.py @@ -12,10 +12,7 @@ from sqlmodel import select from routstr.core.db import ApiKey -from .utils import ( - CashuTokenGenerator, - ResponseValidator, -) +from .utils import ResponseValidator @pytest.mark.integration @@ -79,29 +76,31 @@ async def test_api_key_generation_invalid_token( # Capture initial state await db_snapshot.capture() - # Test various invalid tokens - invalid_tokens = [ - CashuTokenGenerator.generate_invalid_token(), # Malformed token - "not-a-cashu-token", # Wrong format - "cashuA", # Empty token - "cashuA" + "x" * 1000, # Invalid base64 + # Non-Cashu bearer values are invalid API keys (401). Malformed values that + # look like Cashu tokens use the shared Cashu taxonomy (400 invalid_token). + invalid_tokens: list[tuple[str, int, str | None]] = [ + ("not-a-cashu-token", 401, None), + ("sk-not-a-real-api-key", 401, None), + ("cashuA", 400, "invalid_cashu_token"), + ("cashuA" + "x" * 1000, 400, "invalid_cashu_token"), ] - for invalid_token in invalid_tokens: + for invalid_token, expected_status, expected_code in invalid_tokens: integration_client.headers["Authorization"] = f"Bearer {invalid_token}" response = await integration_client.get("/v1/wallet/info") - # Should fail with 401 - assert response.status_code == 401, ( + assert response.status_code == expected_status, ( f"Token {invalid_token[:20]}... should be invalid" ) # Validate error response validator = ResponseValidator() error_validation = validator.validate_error_response( - response, expected_status=401, expected_error_key="detail" + response, expected_status=expected_status, expected_error_key="detail" ) assert error_validation["valid"] + if expected_code is not None: + assert response.json()["detail"]["error"]["code"] == expected_code # Verify no database changes diff = await db_snapshot.diff() diff --git a/tests/unit/test_auth_cashu.py b/tests/unit/test_auth_cashu.py index 3710c745..1d3e6396 100644 --- a/tests/unit/test_auth_cashu.py +++ b/tests/unit/test_auth_cashu.py @@ -3,6 +3,7 @@ from types import SimpleNamespace from typing import AsyncGenerator, cast from unittest.mock import AsyncMock, patch +import httpx import pytest from fastapi import HTTPException from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine @@ -12,6 +13,19 @@ from sqlmodel.ext.asyncio.session import AsyncSession from routstr.auth import validate_bearer_key from routstr.core.db import ApiKey +from routstr.wallet import MintConnectionError + + +def _value_error_wrapping_transport() -> ValueError: + """A ValueError re-raised ``from`` a real httpx transport error, mirroring + ``wallet.py`` wrapping a connection failure. The sanitized classifier must + still see the mint-unreachable signal through the ``__cause__`` chain.""" + try: + raise httpx.ConnectError("All connection attempts failed") + except httpx.ConnectError as exc: + err = ValueError("Failed to estimate fees: connection failed") + err.__cause__ = exc + return err def _make_engine() -> AsyncEngine: @@ -60,13 +74,46 @@ async def test_failed_first_cashu_redemption_rolls_back_empty_api_key( @pytest.mark.parametrize( - ("error", "expected_status", "expected_type", "expected_message"), + ("error", "expected_status", "expected_type", "expected_message", "expected_code"), [ ( ValueError("Mint Error: Token already spent. (Code: 11001)"), 400, "token_already_spent", "Cashu token already spent", + "cashu_token_already_spent", + ), + ( + # Raw httpx transport error propagated unwrapped from cashu. + httpx.ConnectError("All connection attempts failed"), + 503, + "mint_unreachable", + "Cashu mint is unreachable", + "cashu_mint_unreachable", + ), + ( + # Typed error raised by wallet.py at a wrap site. + MintConnectionError("connect to http://mint:3338 refused"), + 503, + "mint_unreachable", + "Cashu mint is unreachable", + "cashu_mint_unreachable", + ), + ( + # ValueError wrapping the httpx error in its __cause__ chain. + _value_error_wrapping_transport(), + 503, + "mint_unreachable", + "Cashu mint is unreachable", + "cashu_mint_unreachable", + ), + ( + # asyncio.TimeoutError is builtin TimeoutError on 3.11+. + TimeoutError("Timed out connecting to Cashu mint http://mint:3338"), + 503, + "mint_unreachable", + "Cashu mint is unreachable", + "cashu_mint_unreachable", ), ( ValueError( @@ -76,6 +123,7 @@ async def test_failed_first_cashu_redemption_rolls_back_empty_api_key( 422, "mint_error", "Token value is too small to cover swap fees", + "cashu_token_swap_fees_exceed_amount", ), ( ValueError( @@ -84,6 +132,7 @@ async def test_failed_first_cashu_redemption_rolls_back_empty_api_key( 422, "mint_error", "Token value is too small to cover swap fees", + "cashu_token_swap_fees_exceed_amount", ), ( ValueError( @@ -92,18 +141,28 @@ async def test_failed_first_cashu_redemption_rolls_back_empty_api_key( 422, "mint_error", "Failed to swap token from foreign mint", + "cashu_foreign_mint_swap_failed", ), ( ValueError("could not decode token"), 400, "invalid_token", "Invalid Cashu token", + "invalid_cashu_token", ), ( ValueError("some unexpected wallet condition"), 400, "cashu_error", "Failed to redeem Cashu token", + "cashu_token_redemption_failed", + ), + ( + ValueError("Redeemed token amount must be positive, got 0 msats"), + 400, + "cashu_error", + "Failed to redeem Cashu token: token yielded no value", + "cashu_token_zero_value", ), ], ) @@ -114,10 +173,11 @@ async def test_redemption_failure_returns_sanitized_error( expected_status: int, expected_type: str, expected_message: str, + expected_code: str, ) -> None: """Redemption failures reuse the shared X-Cashu taxonomy (carried in - ``type``), expose stable sanitized messages (no raw exception text), and - leave no orphan ApiKey row.""" + ``type``), expose stable sanitized messages and granular machine-readable + ``code`` values, and leave no orphan ApiKey row.""" token = "cashuAredemption_fails_with_specific_error" hashed_key = hashlib.sha256(token.encode()).hexdigest() token_obj = SimpleNamespace(mint="http://mint:3338", unit="sat") @@ -139,7 +199,7 @@ async def test_redemption_failure_returns_sanitized_error( detail = cast(dict[str, dict[str, object]], exc_info.value.detail) error_detail = detail["error"] assert error_detail["type"] == expected_type - assert error_detail["code"] == expected_status + assert error_detail["code"] == expected_code assert error_detail["message"] == expected_message assert str(error) not in cast(str, error_detail["message"]) assert await session.get(ApiKey, hashed_key) is None @@ -208,3 +268,26 @@ async def test_internal_error_with_invalid_keyword_does_not_masquerade( detail = cast(dict[str, dict[str, str]], exc_info.value.detail) assert detail["error"]["code"] == "internal_error" assert await session.get(ApiKey, hashed_key) is None + + +@pytest.mark.asyncio +async def test_malformed_cashu_token_returns_400_invalid_token( + session: AsyncSession, +) -> None: + """A malformed 'cashu...' token that fails to decode maps to 400 + invalid_cashu_token (shared taxonomy), not the generic 401 invalid_api_key.""" + token = "cashuAthis_is_not_a_valid_token" + + with patch( + "routstr.auth.deserialize_token_from_string", + side_effect=ValueError("unable to decode token: bad base64"), + ): + with pytest.raises(HTTPException) as exc_info: + await validate_bearer_key(token, session) + + assert exc_info.value.status_code == 400 + detail = cast(dict[str, dict[str, str]], exc_info.value.detail) + assert detail["error"]["type"] == "invalid_token" + assert detail["error"]["code"] == "invalid_cashu_token" + # Raw decoder text must not leak to the client. + assert "base64" not in detail["error"]["message"] diff --git a/tests/unit/test_balance.py b/tests/unit/test_balance.py index ba9d54da..e5ac57c4 100644 --- a/tests/unit/test_balance.py +++ b/tests/unit/test_balance.py @@ -1,12 +1,13 @@ import json from unittest.mock import AsyncMock, MagicMock, patch +import httpx import pytest from fastapi.responses import JSONResponse -from routstr.balance import refund_wallet_endpoint +from routstr.balance import refund_wallet_endpoint, topup_wallet_endpoint from routstr.core.db import ApiKey, CashuTransaction -from routstr.wallet import credit_balance +from routstr.wallet import MintConnectionError, credit_balance def _make_cashu_tx( @@ -401,3 +402,190 @@ async def test_refund_unknown_sk_bearer_returns_401() -> None: assert exc_info.value.status_code == 401 session.get.assert_awaited_once() + + +# --- Topup redemption error taxonomy (POST /v1/wallet/topup) ------------------ + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "error", + [ + httpx.ConnectError("All connection attempts failed"), + MintConnectionError("connect to mint refused"), + TimeoutError("timed out connecting to mint"), + ], +) +async def test_topup_mint_unreachable_returns_503(error: Exception) -> None: + """A down mint must surface 503 (retryable), not 400 or 500 — the token is + fine, so the client should retry once the mint recovers.""" + from fastapi import HTTPException + + key = _make_api_key(balance=1000) + session = MagicMock() + + with ( + patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)), + patch("routstr.balance.credit_balance", AsyncMock(side_effect=error)), + ): + with pytest.raises(HTTPException) as exc_info: + await topup_wallet_endpoint( + cashu_token="cashuAtoken", key=key, session=session + ) + + assert exc_info.value.status_code == 503 + assert exc_info.value.detail == "Cashu mint is unreachable" + + +@pytest.mark.asyncio +async def test_topup_already_spent_still_returns_400() -> None: + """Regression: the mint-unreachable short-circuit must not swallow the + existing ValueError substring buckets.""" + from fastapi import HTTPException + + key = _make_api_key(balance=1000) + session = MagicMock() + + with ( + patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)), + patch( + "routstr.balance.credit_balance", + AsyncMock(side_effect=ValueError("Token already spent")), + ), + ): + with pytest.raises(HTTPException) as exc_info: + await topup_wallet_endpoint( + cashu_token="cashuAtoken", key=key, session=session + ) + + assert exc_info.value.status_code == 400 + assert exc_info.value.detail == "Cashu token already spent" + + +@pytest.mark.asyncio +async def test_topup_zero_value_returns_400_zero_value_message() -> None: + """A dust/zero redemption maps to the documented zero-value message, not the + generic redemption-failed one.""" + from fastapi import HTTPException + + key = _make_api_key(balance=1000) + session = MagicMock() + + with ( + patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)), + patch( + "routstr.balance.credit_balance", + AsyncMock( + side_effect=ValueError("Redeemed token amount must be positive, got 0 msats") + ), + ), + ): + with pytest.raises(HTTPException) as exc_info: + await topup_wallet_endpoint( + cashu_token="cashuAtoken", key=key, session=session + ) + + assert exc_info.value.status_code == 400 + assert exc_info.value.detail == "Failed to redeem Cashu token: token yielded no value" + + +@pytest.mark.asyncio +async def test_topup_token_consumed_returns_500() -> None: + """A post-redemption crediting failure (token spent) is a non-retryable 500, + not a 4xx that invites a retry.""" + from fastapi import HTTPException + + from routstr.wallet import TokenConsumedError + + key = _make_api_key(balance=1000) + session = MagicMock() + + with ( + patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)), + patch( + "routstr.balance.credit_balance", + AsyncMock(side_effect=TokenConsumedError("credit failed")), + ), + ): + with pytest.raises(HTTPException) as exc_info: + await topup_wallet_endpoint( + cashu_token="cashuAtoken", key=key, session=session + ) + + assert exc_info.value.status_code == 500 + assert exc_info.value.detail == ( + "Token was redeemed but could not be credited; do not retry" + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("error", "expected_status", "expected_detail"), + [ + ( + ValueError( + "Failed to estimate fees: Fees (7 sat) exceed token amount (5 sat)" + ), + 422, + "Token value is too small to cover swap fees", + ), + ( + ValueError( + "Token amount (5 sat) is insufficient to cover melt fees." + ), + 422, + "Token value is too small to cover swap fees", + ), + ( + ValueError("Failed to melt token from foreign mint http://m: boom"), + 422, + "Failed to swap token from foreign mint", + ), + ], +) +async def test_topup_fee_and_swap_failures_return_422( + error: Exception, expected_status: int, expected_detail: str +) -> None: + """Fee/swap failures map to 422 (shared taxonomy), matching the bearer and + X-Cashu paths — previously top-up flattened these to 400.""" + from fastapi import HTTPException + + key = _make_api_key(balance=1000) + session = MagicMock() + + with ( + patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)), + patch("routstr.balance.credit_balance", AsyncMock(side_effect=error)), + ): + with pytest.raises(HTTPException) as exc_info: + await topup_wallet_endpoint( + cashu_token="cashuAtoken", key=key, session=session + ) + + assert exc_info.value.status_code == expected_status + assert exc_info.value.detail == expected_detail + + +@pytest.mark.asyncio +async def test_topup_unexpected_non_valueerror_returns_500() -> None: + """A non-ValueError, non-transport fault is an internal error (500), not a + sanitized 400 — the merged except must preserve this.""" + from fastapi import HTTPException + + key = _make_api_key(balance=1000) + session = MagicMock() + + with ( + patch("routstr.balance.get_billing_key", AsyncMock(return_value=key)), + patch( + "routstr.balance.credit_balance", + AsyncMock(side_effect=RuntimeError("db exploded")), + ), + ): + with pytest.raises(HTTPException) as exc_info: + await topup_wallet_endpoint( + cashu_token="cashuAtoken", key=key, session=session + ) + + assert exc_info.value.status_code == 500 + assert exc_info.value.detail == "Internal server error" diff --git a/tests/unit/test_messages_litellm_dispatch.py b/tests/unit/test_messages_litellm_dispatch.py index 5603137e..d7f919a7 100644 --- a/tests/unit/test_messages_litellm_dispatch.py +++ b/tests/unit/test_messages_litellm_dispatch.py @@ -11,6 +11,7 @@ import os from typing import Any, AsyncIterator from unittest.mock import AsyncMock, MagicMock, patch +import httpx import pytest from fastapi.responses import Response, StreamingResponse @@ -21,6 +22,7 @@ from routstr.core.db import ApiKey # noqa: E402 from routstr.payment.cost_calculation import CostData # noqa: E402 from routstr.payment.models import Architecture, Model, Pricing # noqa: E402 from routstr.upstream.base import BaseUpstreamProvider # noqa: E402 +from routstr.wallet import MintConnectionError, TokenConsumedError # noqa: E402 # --------------------------------------------------------------------------- # Fixtures @@ -1308,3 +1310,320 @@ async def test_dispatch_uses_url_detected_prefix_for_fireworks_custom_row() -> N "fireworks_ai/accounts/fireworks/models/glm-5" ) assert captured_kwargs["api_base"] == "https://api.fireworks.ai/inference/v1" + + +# --------------------------------------------------------------------------- +# X-Cashu redemption error taxonomy (unreachable mint + string codes) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "handler_name", + ["handle_x_cashu", "handle_x_cashu_responses"], +) +@pytest.mark.parametrize( + "error", + [ + httpx.ConnectError("All connection attempts failed"), + MintConnectionError("Cashu mint is unreachable"), + TimeoutError("timed out connecting to mint"), + ], +) +async def test_x_cashu_mint_unreachable_returns_503( + handler_name: str, error: Exception +) -> None: + """Both X-Cashu entrypoints classify a down mint as 503 mint_unreachable, + not a generic 400 cashu_error.""" + provider = _make_provider() + model = _make_model() + request = _make_request() + + with patch( + "routstr.upstream.base.recieve_token", new=AsyncMock(side_effect=error) + ): + handler = getattr(provider, handler_name) + response = await handler( + request=request, + x_cashu_token="cashuAtoken", + path="v1/chat/completions", + max_cost_for_model=10_000, + model_obj=model, + ) + + assert response.status_code == 503 + body = json.loads(bytes(response.body)) + assert body["error"]["type"] == "mint_unreachable" + assert body["error"]["message"] == "Cashu mint is unreachable" + assert body["error"]["code"] == "cashu_mint_unreachable" + if str(error) != body["error"]["message"]: + assert str(error) not in body["error"]["message"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "handler_name", + ["handle_x_cashu", "handle_x_cashu_responses"], +) +@pytest.mark.parametrize( + ( + "error", + "expected_status", + "expected_type", + "expected_message", + "expected_code", + ), + [ + ( + ValueError("Mint Error: Token already spent"), + 400, + "token_already_spent", + "Cashu token already spent", + "cashu_token_already_spent", + ), + ( + ValueError("invalid token: could not decode"), + 400, + "invalid_token", + "Invalid Cashu token", + "invalid_cashu_token", + ), + ( + # Fee/swap failures now map to a granular 422 on the X-Cashu path, + # matching the bearer path (previously flattened to 400). + ValueError( + "Failed to estimate fees: Fees (7 sat) exceed token amount (5 sat)" + ), + 422, + "mint_error", + "Token value is too small to cover swap fees", + "cashu_token_swap_fees_exceed_amount", + ), + ( + ValueError("Failed to melt token from foreign mint http://m: boom"), + 422, + "mint_error", + "Failed to swap token from foreign mint", + "cashu_foreign_mint_swap_failed", + ), + ( + ValueError("some unexpected wallet condition"), + 400, + "cashu_error", + "Failed to redeem Cashu token", + "cashu_token_redemption_failed", + ), + ( + # Non-ValueError faults are internal errors (500), not token errors. + RuntimeError("db exploded"), + 500, + "api_error", + "Internal error during token redemption", + "internal_error", + ), + ], +) +async def test_x_cashu_error_code_is_stable_string( + handler_name: str, + error: Exception, + expected_status: int, + expected_type: str, + expected_message: str, + expected_code: str, +) -> None: + """X-Cashu emits a stable string ``code`` on every branch, matching the + bearer path's taxonomy instead of an int HTTP status.""" + provider = _make_provider() + model = _make_model() + request = _make_request() + + with patch( + "routstr.upstream.base.recieve_token", new=AsyncMock(side_effect=error) + ): + handler = getattr(provider, handler_name) + response = await handler( + request=request, + x_cashu_token="cashuAtoken", + path="v1/chat/completions", + max_cost_for_model=10_000, + model_obj=model, + ) + + assert response.status_code == expected_status + body = json.loads(bytes(response.body)) + assert body["error"]["type"] == expected_type + assert body["error"]["message"] == expected_message + assert body["error"]["code"] == expected_code + assert isinstance(body["error"]["code"], str) + if str(error) != expected_message: + assert str(error) not in body["error"]["message"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("handler_name", "forward_attr"), + [ + ("handle_x_cashu", "forward_x_cashu_request"), + ("handle_x_cashu_responses", "forward_x_cashu_responses_request"), + ], +) +async def test_x_cashu_transport_error_after_redemption_is_not_retryable( + handler_name: str, forward_attr: str +) -> None: + """A transport failure while forwarding (after the token is spent) maps to + 502 upstream_error, never a retryable cashu_mint_unreachable.""" + provider = _make_provider() + model = _make_model() + request = _make_request() + + with ( + patch( + "routstr.upstream.base.recieve_token", + new=AsyncMock(return_value=(5_000, "sat", "https://mint")), + ), + patch("routstr.upstream.base.store_cashu_transaction", new=AsyncMock()), + patch.object( + provider, + forward_attr, + new=AsyncMock(side_effect=httpx.ConnectError("upstream down")), + ), + ): + handler = getattr(provider, handler_name) + response = await handler( + request=request, + x_cashu_token="cashuAtoken", + path="v1/chat/completions", + max_cost_for_model=10_000, + model_obj=model, + ) + + assert response.status_code == 502 + body = json.loads(bytes(response.body)) + assert body["error"]["type"] == "upstream_error" + assert body["error"]["code"] != "cashu_mint_unreachable" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "handler_name", + ["handle_x_cashu", "handle_x_cashu_responses"], +) +async def test_x_cashu_token_consumed_returns_500_and_no_echo( + handler_name: str, +) -> None: + """A post-redemption failure (token spent, crediting/minting failed) is a + non-retryable 500 token_consumed and must NOT echo the spent token back.""" + provider = _make_provider() + model = _make_model() + request = _make_request() + + with patch( + "routstr.upstream.base.recieve_token", + new=AsyncMock(side_effect=TokenConsumedError("credit failed")), + ): + handler = getattr(provider, handler_name) + response = await handler( + request=request, + x_cashu_token="cashuAtoken", + path="v1/chat/completions", + max_cost_for_model=10_000, + model_obj=model, + ) + + assert response.status_code == 500 + body = json.loads(bytes(response.body)) + assert body["error"]["type"] == "token_consumed" + assert body["error"]["code"] == "cashu_token_consumed" + assert "X-Cashu" not in response.headers + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("handler_name", "error", "echoed"), + [ + # Spent token: must NOT be echoed. + ("handle_x_cashu", ValueError("Token already spent"), False), + ("handle_x_cashu_responses", ValueError("Token already spent"), False), + # Unspent but unreachable mint: echo so the client can retry the token. + ("handle_x_cashu", MintConnectionError("mint down"), True), + ("handle_x_cashu_responses", MintConnectionError("mint down"), True), + # Consumed token (post-redemption): must NOT be echoed. + ("handle_x_cashu", TokenConsumedError("credit failed"), False), + ("handle_x_cashu_responses", TokenConsumedError("credit failed"), False), + ], +) +async def test_x_cashu_echoes_token_only_when_recoverable( + handler_name: str, error: Exception, echoed: bool +) -> None: + provider = _make_provider() + model = _make_model() + request = _make_request() + + with patch( + "routstr.upstream.base.recieve_token", new=AsyncMock(side_effect=error) + ): + handler = getattr(provider, handler_name) + response = await handler( + request=request, + x_cashu_token="cashuAtoken", + path="v1/chat/completions", + max_cost_for_model=10_000, + model_obj=model, + ) + + if echoed: + assert response.headers.get("X-Cashu") == "cashuAtoken" + else: + assert "X-Cashu" not in response.headers + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("handler_name", "forward_attr"), + [ + ("handle_x_cashu", "forward_x_cashu_request"), + ("handle_x_cashu_responses", "forward_x_cashu_responses_request"), + ], +) +@pytest.mark.parametrize("amount", [0, -5]) +async def test_x_cashu_zero_value_rejected_not_forwarded( + handler_name: str, forward_attr: str, amount: int +) -> None: + """A token that redeems to <= 0 must be rejected as cashu_token_zero_value + (400) and NEVER forwarded as a free request — the X-Cashu path lacked the + guard that credit_balance has.""" + provider = _make_provider() + model = _make_model() + request = _make_request() + + with ( + patch( + "routstr.upstream.base.recieve_token", + new=AsyncMock(return_value=(amount, "sat", "https://mint")), + ), + patch("routstr.upstream.base.store_cashu_transaction", new=AsyncMock()), + patch.object( + provider, + forward_attr, + new=AsyncMock(side_effect=AssertionError("must not forward a zero-value token")), + ), + ): + handler = getattr(provider, handler_name) + response = await handler( + request=request, + x_cashu_token="cashuAtoken", + path="v1/chat/completions", + max_cost_for_model=10_000, + model_obj=model, + ) + + assert response.status_code == 400 + body = json.loads(bytes(response.body)) + assert body["error"]["type"] == "cashu_error" + assert ( + body["error"]["message"] + == "Failed to redeem Cashu token: token yielded no value" + ) + assert body["error"]["code"] == "cashu_token_zero_value" + # Spent-to-zero token must not be echoed back for retry. + assert "X-Cashu" not in response.headers diff --git a/tests/unit/test_wallet.py b/tests/unit/test_wallet.py index 8f7c7a1b..3bb36a28 100644 --- a/tests/unit/test_wallet.py +++ b/tests/unit/test_wallet.py @@ -1,11 +1,22 @@ import base64 import json +import socket from unittest.mock import AsyncMock, Mock, patch +import httpx import pytest from routstr.core.db import ApiKey -from routstr.wallet import credit_balance, get_balance, recieve_token, send_token +from routstr.wallet import ( + MintConnectionError, + TokenConsumedError, + classify_redemption_error, + credit_balance, + get_balance, + is_mint_connection_error, + recieve_token, + send_token, +) @pytest.mark.asyncio @@ -231,9 +242,15 @@ async def test_credit_balance_rejects_missing_key() -> None: "routstr.wallet.recieve_token", return_value=(1000, "sat", "http://mint:3338"), ): - with pytest.raises(ValueError, match="disappeared"): + # Post-redemption: token already spent, so a vanished key is a + # non-retryable TokenConsumedError, not a generic token error. + with pytest.raises(TokenConsumedError, match="disappeared") as exc_info: await credit_balance(token_str, mock_key, mock_session) + classified = classify_redemption_error(exc_info.value) + assert classified is not None + _type, status, _msg, code = classified + assert (status, code) == (500, "cashu_token_consumed") # UPDATE matched nothing; committing would hide the failed credit. assert mock_session.exec.called assert not mock_session.commit.called @@ -902,9 +919,10 @@ def _with_recovery_mocks( @pytest.mark.asyncio -async def test_swap_mint_failure_propagates_unwrapped() -> None: - """A non-recoverable mint failure after melt propagates as-is (a 500, not a - client error): the melt already spent the foreign proofs.""" +async def test_swap_mint_failure_after_melt_is_token_consumed() -> None: + """A non-recoverable mint failure after melt is a non-retryable + TokenConsumedError (the melt already spent the foreign proofs), with the + original error preserved in the cause chain.""" from routstr.wallet import swap_to_primary_mint mock_token, mock_token_wallet, mock_primary_wallet = _make_swap_mocks( @@ -919,10 +937,10 @@ async def test_swap_mint_failure_propagates_unwrapped() -> None: with patch.object(settings, "primary_mint", "http://primary:3338"): with patch.object(settings, "primary_mint_unit", "sat"): with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet): - with pytest.raises(Exception, match="Quote is expired") as exc_info: + with pytest.raises(TokenConsumedError) as exc_info: await swap_to_primary_mint(mock_token, mock_token_wallet) - assert type(exc_info.value) is Exception # original error, not wrapped + assert "Quote is expired" in str(exc_info.value.__cause__) assert mock_token_wallet.melt.call_count == 1 mock_primary_wallet.restore_tokens_for_keyset.assert_not_called() @@ -977,7 +995,7 @@ async def test_swap_recovery_shortfall_refuses_credit() -> None: with patch.object(settings, "primary_mint", "http://primary:3338"): with patch.object(settings, "primary_mint_unit", "sat"): with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet): - with pytest.raises(ValueError, match="Swap recovery failed"): + with pytest.raises(TokenConsumedError, match="Swap recovery failed"): await swap_to_primary_mint(mock_token, mock_token_wallet) @@ -1004,7 +1022,7 @@ async def test_swap_recovery_failure_wrapped() -> None: with patch.object(settings, "primary_mint", "http://primary:3338"): with patch.object(settings, "primary_mint_unit", "sat"): with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet): - with pytest.raises(ValueError, match="recovery unsuccessful"): + with pytest.raises(TokenConsumedError, match="recovery unsuccessful"): await swap_to_primary_mint(mock_token, mock_token_wallet) @@ -1092,3 +1110,214 @@ async def test_swap_does_not_retry_on_payment_failure() -> None: assert mock_token_wallet.melt.call_count == 1 assert mock_primary_wallet.request_mint.call_count == 2 + + +# --- Mint-unreachable classification (is_mint_connection_error) --------------- + + +def _chain(outer: BaseException, cause: BaseException) -> BaseException: + """Attach ``cause`` as the ``__cause__`` of ``outer`` (as ``raise X from Y`` + would) and return ``outer``.""" + outer.__cause__ = cause + return outer + + +@pytest.mark.parametrize( + "error", + [ + httpx.ConnectError("connection refused"), + httpx.ConnectTimeout("timed out"), + httpx.ReadTimeout("read timed out"), # subclass of TimeoutException + httpx.PoolTimeout("pool timed out"), + httpx.WriteError("write failed"), # subclass of NetworkError + ConnectionRefusedError("refused"), # subclass of ConnectionError + ConnectionResetError("reset"), + socket.gaierror("Name or service not known"), + TimeoutError("timed out"), # asyncio.TimeoutError alias on 3.11+ + MintConnectionError("mint down"), + # Wrapped: the real transport error survives in the __cause__ chain. + _chain(ValueError("Failed to estimate fees: boom"), httpx.ConnectError("x")), + # Two levels deep. + _chain( + RuntimeError("outer"), + _chain(ValueError("mid"), httpx.ConnectTimeout("deep")), + ), + ], +) +def test_is_mint_connection_error_detects_transport_failures( + error: BaseException, +) -> None: + assert is_mint_connection_error(error) is True + + +@pytest.mark.parametrize( + "error", + [ + ValueError("token already spent"), + ValueError("Mint unreachable: all connection attempts failed"), # text only + ValueError("Invalid Cashu token"), + # Mint answered with an error status — reachable, so NOT a connection error. + httpx.HTTPStatusError( + "500", request=httpx.Request("POST", "http://m"), response=httpx.Response(500) + ), + RuntimeError("some internal fault"), + ], +) +def test_is_mint_connection_error_ignores_non_transport(error: BaseException) -> None: + assert is_mint_connection_error(error) is False + + +def test_is_mint_connection_error_survives_reference_cycle() -> None: + """A pathological cause/context cycle must not hang the classifier.""" + a = ValueError("a") + b = ValueError("b") + a.__cause__ = b + b.__context__ = a + assert is_mint_connection_error(a) is False + + +def test_token_consumed_seals_transport_cause() -> None: + """A transport error wrapped in TokenConsumedError is NOT retryable — the + token is spent, so the seal wins over the httpx cause underneath.""" + try: + raise httpx.ConnectError("mint down") + except httpx.ConnectError as exc: + consumed = TokenConsumedError("credit failed") + consumed.__cause__ = exc + + assert is_mint_connection_error(consumed) is False + classified = classify_redemption_error(consumed) + assert classified is not None + type_, status, _msg, code = classified + assert (type_, status, code) == ("token_consumed", 500, "cashu_token_consumed") + + +@pytest.mark.parametrize( + "error", + [ + # The message credit_balance raises for a dust/zero redemption. + ValueError("Redeemed token amount must be positive, got 0 msats"), + ValueError("Redeemed token amount must be positive, got -5 msats"), + ValueError("Failed to redeem Cashu token: token yielded no value"), + ], +) +def test_classify_zero_value(error: ValueError) -> None: + """A zero/negative redemption gets its own documented code, not the generic + cashu_token_redemption_failed bucket.""" + classified = classify_redemption_error(error) + assert classified is not None + type_, status, _msg, code = classified + assert (type_, status, code) == ("cashu_error", 400, "cashu_token_zero_value") + + +def test_classify_generic_valueerror_is_not_zero_value() -> None: + """A generic wallet ValueError still falls to the generic bucket — the + zero-value match must not over-trigger.""" + classified = classify_redemption_error(ValueError("some unexpected wallet condition")) + assert classified is not None + type_, status, _msg, code = classified + assert (type_, status, code) == ( + "cashu_error", + 400, + "cashu_token_redemption_failed", + ) + + +@pytest.mark.asyncio +async def test_swap_mint_transport_error_after_melt_is_not_retryable() -> None: + """A transport error minting on the primary mint (after the foreign melt + already spent the proofs) classifies as a non-retryable token_consumed 500, + never a retryable mint_unreachable 503.""" + from routstr.wallet import swap_to_primary_mint + + mock_token, mock_token_wallet, mock_primary_wallet = _make_swap_mocks( + 1000, fee_reserves=[10, 10] + ) + # Melt succeeds (proofs spent); minting on primary hits a transport error. + mock_primary_wallet.mint = AsyncMock( + side_effect=httpx.ConnectError("primary mint down") + ) + + from routstr.core.settings import settings + + with patch.object(settings, "primary_mint", "http://primary:3338"): + with patch.object(settings, "primary_mint_unit", "sat"): + with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet): + with pytest.raises(TokenConsumedError) as exc_info: + await swap_to_primary_mint(mock_token, mock_token_wallet) + + classified = classify_redemption_error(exc_info.value) + assert classified is not None + _type, status, _msg, code = classified + assert status == 500 + assert code == "cashu_token_consumed" + assert is_mint_connection_error(exc_info.value) is False + assert mock_token_wallet.melt.call_count == 1 + + +@pytest.mark.asyncio +async def test_credit_balance_db_transport_error_is_token_consumed() -> None: + """A transport-like DB failure after the token is redeemed must be + non-retryable (token_consumed), not a retryable mint_unreachable.""" + mock_key = Mock() + mock_key.balance = 1000 + mock_key.hashed_key = "test_hash" + mock_session = AsyncMock() + mock_session.exec = AsyncMock(side_effect=ConnectionError("db connection reset")) + + with patch( + "routstr.wallet.recieve_token", + return_value=(100, "sat", "https://mint.example"), + ): + with pytest.raises(TokenConsumedError) as exc_info: + await credit_balance("cashuAtoken", mock_key, mock_session) + + assert is_mint_connection_error(exc_info.value) is False + + +@pytest.mark.asyncio +async def test_swap_fee_estimation_transport_error_raises_mint_connection_error() -> None: + """A transport failure while estimating fees is surfaced as + MintConnectionError (→ 503), not a generic fee ValueError (→ 422).""" + from routstr.wallet import swap_to_primary_mint + + mock_token, mock_token_wallet, mock_primary_wallet = _make_swap_mocks( + 1000, fee_reserves=[10] + ) + mock_primary_wallet.request_mint = AsyncMock( + side_effect=httpx.ConnectError("All connection attempts failed") + ) + + from routstr.core.settings import settings + + with patch.object(settings, "primary_mint", "http://primary:3338"): + with patch.object(settings, "primary_mint_unit", "sat"): + with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet): + with pytest.raises(MintConnectionError): + await swap_to_primary_mint(mock_token, mock_token_wallet) + + mock_token_wallet.melt.assert_not_called() + + +@pytest.mark.asyncio +async def test_swap_melt_transport_error_raises_mint_connection_error() -> None: + """A transport failure during melt is surfaced as MintConnectionError and + is NOT retried — the mint is down, not demanding higher fees.""" + from routstr.wallet import swap_to_primary_mint + + mock_token, mock_token_wallet, mock_primary_wallet = _make_swap_mocks( + 1000, fee_reserves=[10, 10] + ) + mock_token_wallet.melt = AsyncMock( + side_effect=httpx.ConnectTimeout("timed out") + ) + + from routstr.core.settings import settings + + with patch.object(settings, "primary_mint", "http://primary:3338"): + with patch.object(settings, "primary_mint_unit", "sat"): + with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet): + with pytest.raises(MintConnectionError): + await swap_to_primary_mint(mock_token, mock_token_wallet) + + assert mock_token_wallet.melt.call_count == 1