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:
|
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,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user