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:
"""Map a Cashu token redemption failure to a sanitized client-facing error.
Known redemption failure patterns (from the wallet or the mint) and expected
wallet errors (ValueError) map to 400/401 with a stable message under a
single "token_redemption_failed" code. Anything else is an internal fault
and maps to a generic 500. Raw error text never reaches the client — it
stays in server logs.
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.
"""
lowered = str(error).lower()
status_code = 400
# (type, status, message) — type mirrors the X-Cashu taxonomy strings.
if "already spent" in lowered:
message = "Cashu token already spent"
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
):
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:
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:
# 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 401 token error.
message = "Invalid Cashu token"
status_code = 401
# instead of masquerading as a token error.
error_type, status_code, message = (
"invalid_token",
400,
"Invalid Cashu token",
)
elif isinstance(error, ValueError):
message = "Failed to redeem Cashu token"
error_type, status_code, message = (
"cashu_error",
400,
"Failed to redeem Cashu token",
)
else:
return HTTPException(
status_code=500,
@@ -125,8 +146,8 @@ def redemption_error_to_http_exception(error: Exception) -> HTTPException:
detail={
"error": {
"message": message,
"type": "invalid_request_error",
"code": "token_redemption_failed",
"type": error_type,
"code": status_code,
}
},
)
@@ -396,9 +417,12 @@ async def validate_bearer_key(
"Token redemption returned zero or negative amount",
extra={"msats": msats, "key_hash": hashed_key[:8] + "..."},
)
# Defense-in-depth: credit_balance now refuses to commit on a
# zero/negative redemption, but if a row was nonetheless
# persisted, drop it so we never leave an orphan zero-balance key.
# Defense-in-depth: credit_balance already raises
# ValueError("Redeemed token amount must be positive…") before
# 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.commit()
raise HTTPException(
@@ -406,8 +430,8 @@ async def validate_bearer_key(
detail={
"error": {
"message": "Failed to redeem Cashu token: token yielded no value",
"type": "invalid_request_error",
"code": "token_redemption_failed",
"type": "cashu_error",
"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(
("error", "expected_status", "expected_message"),
("error", "expected_status", "expected_type", "expected_message"),
[
(
ValueError("Mint Error: Token already spent. (Code: 11001)"),
400,
"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. "
"Needed: 7 sat (amount: 5 + fee: 1 + input_fees: 1)"
),
400,
422,
"mint_error",
"Token value is too small to cover swap fees",
),
(
ValueError(
"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",
),
(
ValueError(
"Failed to melt token from foreign mint http://foreign:3338: boom"
),
400,
422,
"mint_error",
"Failed to swap token from foreign mint",
),
(
ValueError("could not decode token"),
401,
400,
"invalid_token",
"Invalid Cashu token",
),
(
ValueError("some unexpected wallet condition"),
400,
"cashu_error",
"Failed to redeem Cashu token",
),
],
@@ -106,10 +112,12 @@ async def test_redemption_failure_returns_sanitized_error(
session: AsyncSession,
error: Exception,
expected_status: int,
expected_type: str,
expected_message: str,
) -> None:
"""Redemption failures share one error code, expose stable sanitized
messages (no raw exception text), and leave no orphan ApiKey row."""
"""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."""
token = "cashuAredemption_fails_with_specific_error"
hashed_key = hashlib.sha256(token.encode()).hexdigest()
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)
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"]
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 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