mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 20:28:23 +00:00
Merge pull request #662 from Routstr/add-missing-error-code
add missing error code from reponse endpoint
This commit is contained in:
+22
-18
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user