refactor: align bearer redemption errors with shared X-Cashu taxonomy

Route the Authorization: Bearer cashu… redemption failures through the
same failure taxonomy the X-Cashu path already uses (token_already_spent /
invalid_token / mint_error / cashu_error, carried in the error "type"),
with matching statuses, so both redemption paths agree on the
classification for a given failure class instead of introducing a third
parallel code.

- "already spent" -> token_already_spent (400)
- fee/melt failures -> mint_error (422)
- invalid/undecodable token -> invalid_token (400, was 401)
- other expected wallet errors -> cashu_error (400)
- unexpected faults still -> internal_error (500)

Also align the msats<=0 defense-in-depth envelope and note it is now
practically unreachable (credit_balance rejects non-positive amounts).
This commit is contained in:
9qeklajc
2026-07-05 17:14:30 +02:00
parent 9dc4c979b8
commit 66bc1260a4
2 changed files with 63 additions and 30 deletions
+44 -20
View File
@@ -81,34 +81,55 @@ async def check_and_reset_limit(key: ApiKey, session: AsyncSession) -> bool:
def redemption_error_to_http_exception(error: Exception) -> HTTPException: def redemption_error_to_http_exception(error: Exception) -> HTTPException:
"""Map a Cashu token redemption failure to a sanitized client-facing error. """Map a Cashu token redemption failure to a sanitized client-facing error.
Known redemption failure patterns (from the wallet or the mint) and expected Uses the same failure taxonomy as the X-Cashu redemption path
wallet errors (ValueError) map to 400/401 with a stable message under a (``token_already_spent``/``invalid_token``/``mint_error``/``cashu_error``,
single "token_redemption_failed" code. Anything else is an internal fault see ``create_error_response`` in ``payment/helpers.py``) so both paths that
and maps to a generic 500. Raw error text never reaches the client — it redeem a token agree on the classification (carried in ``type``) and status.
stays in server logs. 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.
""" """
lowered = str(error).lower() lowered = str(error).lower()
status_code = 400 # (type, status, message) — type mirrors the X-Cashu taxonomy strings.
if "already spent" in lowered: if "already spent" in lowered:
message = "Cashu token already spent" error_type, status_code, message = (
"token_already_spent",
400,
"Cashu token already spent",
)
elif ( elif (
"insufficient" in lowered "insufficient" in lowered
or "melt fee" in lowered or "melt fee" in lowered
or "exceed token amount" in lowered or "exceed token amount" in lowered
or "estimate fees" in lowered or "estimate fees" in lowered
): ):
message = "Token value is too small to cover swap fees" error_type, status_code, message = (
"mint_error",
422,
"Token value is too small to cover swap fees",
)
elif "failed to melt" in lowered: elif "failed to melt" in lowered:
message = "Failed to swap token from foreign mint" 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: elif ("invalid" in lowered or "decode" in lowered) and "token" in lowered:
# Anchor the broad buckets to "token" so internal faults whose text # Anchor the broad buckets to "token" so internal faults whose text
# merely contains "invalid"/"decode" (e.g. "invalid literal", # merely contains "invalid"/"decode" (e.g. "invalid literal",
# SQLAlchemy "Invalid …") fall through to the generic 500 below # SQLAlchemy "Invalid …") fall through to the generic 500 below
# instead of masquerading as a 401 token error. # instead of masquerading as a token error.
message = "Invalid Cashu token" error_type, status_code, message = (
status_code = 401 "invalid_token",
400,
"Invalid Cashu token",
)
elif isinstance(error, ValueError): elif isinstance(error, ValueError):
message = "Failed to redeem Cashu token" error_type, status_code, message = (
"cashu_error",
400,
"Failed to redeem Cashu token",
)
else: else:
return HTTPException( return HTTPException(
status_code=500, status_code=500,
@@ -125,8 +146,8 @@ def redemption_error_to_http_exception(error: Exception) -> HTTPException:
detail={ detail={
"error": { "error": {
"message": message, "message": message,
"type": "invalid_request_error", "type": error_type,
"code": "token_redemption_failed", "code": status_code,
} }
}, },
) )
@@ -396,9 +417,12 @@ async def validate_bearer_key(
"Token redemption returned zero or negative amount", "Token redemption returned zero or negative amount",
extra={"msats": msats, "key_hash": hashed_key[:8] + "..."}, extra={"msats": msats, "key_hash": hashed_key[:8] + "..."},
) )
# Defense-in-depth: credit_balance now refuses to commit on a # Defense-in-depth: credit_balance already raises
# zero/negative redemption, but if a row was nonetheless # ValueError("Redeemed token amount must be positive…") before
# persisted, drop it so we never leave an orphan zero-balance key. # returning (wallet.py), so this branch is only reachable if a
# zero/negative row was somehow persisted; drop it so we never
# leave an orphan zero-balance key. Reuse the shared taxonomy
# (cashu_error) so the envelope matches the mapper above.
await session.delete(new_key) await session.delete(new_key)
await session.commit() await session.commit()
raise HTTPException( raise HTTPException(
@@ -406,8 +430,8 @@ async def validate_bearer_key(
detail={ detail={
"error": { "error": {
"message": "Failed to redeem Cashu token: token yielded no value", "message": "Failed to redeem Cashu token: token yielded no value",
"type": "invalid_request_error", "type": "cashu_error",
"code": "token_redemption_failed", "code": 400,
} }
}, },
) )
+19 -10
View File
@@ -60,11 +60,12 @@ async def test_failed_first_cashu_redemption_rolls_back_empty_api_key(
@pytest.mark.parametrize( @pytest.mark.parametrize(
("error", "expected_status", "expected_message"), ("error", "expected_status", "expected_type", "expected_message"),
[ [
( (
ValueError("Mint Error: Token already spent. (Code: 11001)"), ValueError("Mint Error: Token already spent. (Code: 11001)"),
400, 400,
"token_already_spent",
"Cashu token already spent", "Cashu token already spent",
), ),
( (
@@ -72,31 +73,36 @@ async def test_failed_first_cashu_redemption_rolls_back_empty_api_key(
"Token amount (5 sat) is insufficient to cover melt fees. " "Token amount (5 sat) is insufficient to cover melt fees. "
"Needed: 7 sat (amount: 5 + fee: 1 + input_fees: 1)" "Needed: 7 sat (amount: 5 + fee: 1 + input_fees: 1)"
), ),
400, 422,
"mint_error",
"Token value is too small to cover swap fees", "Token value is too small to cover swap fees",
), ),
( (
ValueError( ValueError(
"Failed to estimate fees: Fees (7 sat) exceed token amount (5 sat)" "Failed to estimate fees: Fees (7 sat) exceed token amount (5 sat)"
), ),
400, 422,
"mint_error",
"Token value is too small to cover swap fees", "Token value is too small to cover swap fees",
), ),
( (
ValueError( ValueError(
"Failed to melt token from foreign mint http://foreign:3338: boom" "Failed to melt token from foreign mint http://foreign:3338: boom"
), ),
400, 422,
"mint_error",
"Failed to swap token from foreign mint", "Failed to swap token from foreign mint",
), ),
( (
ValueError("could not decode token"), ValueError("could not decode token"),
401, 400,
"invalid_token",
"Invalid Cashu token", "Invalid Cashu token",
), ),
( (
ValueError("some unexpected wallet condition"), ValueError("some unexpected wallet condition"),
400, 400,
"cashu_error",
"Failed to redeem Cashu token", "Failed to redeem Cashu token",
), ),
], ],
@@ -106,10 +112,12 @@ async def test_redemption_failure_returns_sanitized_error(
session: AsyncSession, session: AsyncSession,
error: Exception, error: Exception,
expected_status: int, expected_status: int,
expected_type: str,
expected_message: str, expected_message: str,
) -> None: ) -> None:
"""Redemption failures share one error code, expose stable sanitized """Redemption failures reuse the shared X-Cashu taxonomy (carried in
messages (no raw exception text), and leave no orphan ApiKey row.""" ``type``), expose stable sanitized messages (no raw exception text), and
leave no orphan ApiKey row."""
token = "cashuAredemption_fails_with_specific_error" token = "cashuAredemption_fails_with_specific_error"
hashed_key = hashlib.sha256(token.encode()).hexdigest() hashed_key = hashlib.sha256(token.encode()).hexdigest()
token_obj = SimpleNamespace(mint="http://mint:3338", unit="sat") token_obj = SimpleNamespace(mint="http://mint:3338", unit="sat")
@@ -128,11 +136,12 @@ async def test_redemption_failure_returns_sanitized_error(
await validate_bearer_key(token, session) await validate_bearer_key(token, session)
assert exc_info.value.status_code == expected_status assert exc_info.value.status_code == expected_status
detail = cast(dict[str, dict[str, str]], exc_info.value.detail) detail = cast(dict[str, dict[str, object]], exc_info.value.detail)
error_detail = detail["error"] error_detail = detail["error"]
assert error_detail["code"] == "token_redemption_failed" assert error_detail["type"] == expected_type
assert error_detail["code"] == expected_status
assert error_detail["message"] == expected_message assert error_detail["message"] == expected_message
assert str(error) not in error_detail["message"] assert str(error) not in cast(str, error_detail["message"])
assert await session.get(ApiKey, hashed_key) is None assert await session.get(ApiKey, hashed_key) is None