explicit code for error report & update doc

This commit is contained in:
9qeklajc
2026-07-05 23:05:53 +02:00
parent 66bc1260a4
commit e2aa02307b
11 changed files with 1238 additions and 197 deletions
+87 -20
View File
@@ -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 <cashu-token>`.
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 <cashu-token>`) 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 <cashu-token>`** (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
+23 -53
View File
@@ -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",
}
},
)
+18 -24
View File
@@ -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}
+71 -50
View File
@@ -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:
+188 -17
View File
@@ -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",
+4 -4
View File
@@ -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()
+13 -14
View File
@@ -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()
+87 -4
View File
@@ -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"]
+190 -2
View File
@@ -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"
@@ -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
+238 -9
View File
@@ -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