Merge pull request #662 from Routstr/add-missing-error-code

add missing error code from reponse endpoint
This commit is contained in:
9qeklajc
2026-08-12 22:06:52 +02:00
committed by GitHub
6 changed files with 94 additions and 34 deletions
+22 -18
View File
@@ -123,10 +123,9 @@ They apply to every endpoint that accepts a token:
- **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.
and sanitized message everywhere. All three paths also expose the same
structured `type` and `code` — branch on `type` (or `code` for finer
granularity) on any of them.
| `type` | Status | `code` | Retryable | Meaning |
|--------|--------|--------|-----------|---------|
@@ -134,20 +133,26 @@ keeps its existing plain-string `detail` envelope, so branch on status there.
| `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_source_mint_unreachable` | **Yes** | The mint that issued the token could not be reached; it cannot be redeemed at another mint. |
| `mint_rate_limited` | 503 | `cashu_mint_rate_limited` | **Yes** | The mint rate-limited the request; retry after the cooldown. |
| `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`.
!!! important "Retry only transient mint failures"
Only `mint_unreachable` and `mint_rate_limited` (503) are retryable — the
same token may work again later. Everything else is a permanent property of
the token and must not be blindly retried. Use exponential backoff for the
503 responses, and honor the mint's cooldown for `mint_rate_limited`. 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)
#### Mint failures (retryable)
`mint_unreachable` and `mint_rate_limited` are retryable redemption errors. For
`mint_rate_limited`, honor the mint's cooldown before retrying.
```json
{
@@ -162,7 +167,8 @@ keeps its existing plain-string `detail` envelope, so branch on status there.
**Status:** 503
**Resolution:** The token is valid — the mint is temporarily down. Retry with
backoff, or pay with a token from a different mint.
backoff, or pay with a token from a different mint. If the mint returns
`mint_rate_limited`, wait for its cooldown before retrying.
#### Token Already Spent
@@ -196,7 +202,7 @@ depends on how you paid:
```
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
it is still spendable** (e.g. `mint_unreachable`, `mint_rate_limited`, `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.
@@ -208,13 +214,11 @@ depends on how you paid:
{ "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:
- **`POST /v1/wallet/topup`** returns the same structured envelope wrapped in
FastAPI's `detail` field, identical to the bearer path:
```json
{ "detail": "Cashu mint is unreachable" }
{ "detail": { "error": { "type": "mint_unreachable", "message": "Cashu mint is unreachable", "code": "cashu_mint_unreachable" } } }
```
### Validation Errors
+7 -3
View File
@@ -9,7 +9,11 @@ from fastapi.responses import JSONResponse
from pydantic import BaseModel
from sqlmodel import col, select, update
from .auth import get_billing_key, validate_bearer_key
from .auth import (
get_billing_key,
redemption_error_to_http_exception,
validate_bearer_key,
)
from .core.db import (
ApiKey,
AsyncSession,
@@ -243,7 +247,7 @@ async def topup_wallet_endpoint(
"error_chain": _error_chain(e),
},
)
raise HTTPException(status_code=500, detail="Internal server error")
raise redemption_error_to_http_exception(e)
error_type, status_code, message, error_code = classified
logger.warning(
"Cashu wallet top-up failed",
@@ -258,7 +262,7 @@ async def topup_wallet_endpoint(
"error_chain": _error_chain(e),
},
)
raise HTTPException(status_code=status_code, detail=message)
raise redemption_error_to_http_exception(e)
logger.info(
"Cashu wallet top-up completed",
@@ -181,7 +181,12 @@ class TestInvalidInputHandling:
# All should fail with 400
assert response.status_code == 400, f"Token {repr(token)} should fail"
# Accept various error messages that indicate token validation failure
error_detail = response.json()["detail"].lower()
raw_detail = response.json()["detail"]
error_detail = (
raw_detail["error"]["message"].lower()
if isinstance(raw_detail, dict)
else raw_detail.lower()
)
assert any(
keyword in error_detail
for keyword in ["invalid", "failed to redeem", "failed to decode"]
+5 -1
View File
@@ -199,6 +199,10 @@ async def test_topup_returns_422_when_retries_exhausted(
)
assert response.status_code == 422
assert "too small to cover swap fees" in response.json()["detail"]
raw_detail = response.json()["detail"]
message = (
raw_detail["error"]["message"] if isinstance(raw_detail, dict) else raw_detail
)
assert "too small to cover swap fees" in message
assert token_wallet.melt_quote.call_count == 4 # estimation + 3 attempts
token_wallet.melt.assert_not_called()
+6 -2
View File
@@ -191,7 +191,9 @@ async def test_topup_with_spent_token( # type: ignore[no-untyped-def]
)
assert response.status_code == 400
assert "spent" in response.json()["detail"].lower()
detail = response.json()["detail"]
message = detail["error"]["message"] if isinstance(detail, dict) else detail
assert "spent" in message.lower()
# Verify no additional balance changes
diff = await db_snapshot.diff()
@@ -390,7 +392,9 @@ async def test_network_failure_during_token_verification( # type: ignore[no-unt
# Should return 500 error for network issues
assert response.status_code == 500
assert "detail" in response.json()
assert response.json()["detail"] == "Internal server error"
detail = response.json()["detail"]
message = detail["error"]["message"] if isinstance(detail, dict) else detail
assert message == "Internal error during token redemption"
@pytest.mark.integration
+48 -9
View File
@@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from fastapi import HTTPException
from fastapi.responses import JSONResponse
from routstr.balance import refund_wallet_endpoint, topup_wallet_endpoint
@@ -582,6 +583,13 @@ async def test_refund_unknown_sk_bearer_returns_401() -> None:
# --- Topup redemption error taxonomy (POST /v1/wallet/topup) ------------------
def _envelope(exc: HTTPException) -> dict:
"""Extract the error object from a top-up HTTPException."""
detail = exc.detail
assert isinstance(detail, dict), f"detail is not a dict: {detail!r}"
assert "error" in detail, f"missing 'error' key in detail: {detail!r}"
return detail["error"]
@pytest.mark.asyncio
@pytest.mark.parametrize(
@@ -610,7 +618,10 @@ async def test_topup_mint_unreachable_returns_503(error: Exception) -> None:
)
assert exc_info.value.status_code == 503
assert exc_info.value.detail == "Cashu mint is unreachable"
err = _envelope(exc_info.value)
assert err["type"] == "mint_unreachable"
assert err["code"] == "cashu_mint_unreachable"
assert err["message"] == "Cashu mint is unreachable"
@pytest.mark.asyncio
@@ -633,7 +644,10 @@ async def test_topup_unreachable_source_mint_explains_why_fallback_is_impossible
)
assert exc_info.value.status_code == 503
assert "cannot be redeemed at another mint" in exc_info.value.detail
err = _envelope(exc_info.value)
assert err["type"] == "mint_unreachable"
assert err["code"] == "cashu_source_mint_unreachable"
assert "cannot be redeemed at another mint" in err["message"]
@pytest.mark.asyncio
@@ -658,7 +672,10 @@ async def test_topup_already_spent_still_returns_400() -> None:
)
assert exc_info.value.status_code == 400
assert exc_info.value.detail == "Cashu token already spent"
err = _envelope(exc_info.value)
assert err["type"] == "token_already_spent"
assert err["code"] == "cashu_token_already_spent"
assert err["message"] == "Cashu token already spent"
@pytest.mark.asyncio
@@ -685,7 +702,10 @@ async def test_topup_zero_value_returns_400_zero_value_message() -> None:
)
assert exc_info.value.status_code == 400
assert exc_info.value.detail == "Failed to redeem Cashu token: token yielded no value"
err = _envelope(exc_info.value)
assert err["type"] == "cashu_error"
assert err["code"] == "cashu_token_zero_value"
assert err["message"] == "Failed to redeem Cashu token: token yielded no value"
@pytest.mark.asyncio
@@ -712,20 +732,25 @@ async def test_topup_token_consumed_returns_500() -> None:
)
assert exc_info.value.status_code == 500
assert exc_info.value.detail == (
err = _envelope(exc_info.value)
assert err["type"] == "token_consumed"
assert err["code"] == "cashu_token_consumed"
assert err["message"] == (
"Token was redeemed but could not be credited; do not retry"
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("error", "expected_status", "expected_detail"),
("error", "expected_status", "expected_type", "expected_code", "expected_message"),
[
(
ValueError(
"Failed to estimate fees: Fees (7 sat) exceed token amount (5 sat)"
),
422,
"mint_error",
"cashu_token_swap_fees_exceed_amount",
"Token value is too small to cover swap fees",
),
(
@@ -733,17 +758,25 @@ async def test_topup_token_consumed_returns_500() -> None:
"Token amount (5 sat) is insufficient to cover melt fees."
),
422,
"mint_error",
"cashu_token_swap_fees_exceed_amount",
"Token value is too small to cover swap fees",
),
(
ValueError("Failed to melt token from foreign mint http://m: boom"),
422,
"mint_error",
"cashu_foreign_mint_swap_failed",
"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
error: Exception,
expected_status: int,
expected_type: str,
expected_code: str,
expected_message: str,
) -> None:
"""Fee/swap failures map to 422 (shared taxonomy), matching the bearer and
X-Cashu paths — previously top-up flattened these to 400."""
@@ -762,7 +795,10 @@ async def test_topup_fee_and_swap_failures_return_422(
)
assert exc_info.value.status_code == expected_status
assert exc_info.value.detail == expected_detail
err = _envelope(exc_info.value)
assert err["type"] == expected_type
assert err["code"] == expected_code
assert err["message"] == expected_message
@pytest.mark.asyncio
@@ -787,7 +823,10 @@ async def test_topup_unexpected_non_valueerror_returns_500() -> None:
)
assert exc_info.value.status_code == 500
assert exc_info.value.detail == "Internal server error"
err = _envelope(exc_info.value)
assert err["type"] == "api_error"
assert err["code"] == "internal_error"
assert err["message"] == "Internal error during token redemption"
@pytest.mark.asyncio