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