diff --git a/docs/api/errors.md b/docs/api/errors.md index e6a41deb..95983de8 100644 --- a/docs/api/errors.md +++ b/docs/api/errors.md @@ -123,10 +123,9 @@ They apply to every endpoint that accepts a token: - **Minting an API key** from a token sent in `Authorization: Bearer `. 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 `) 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 diff --git a/routstr/balance.py b/routstr/balance.py index 22365f39..e7d4468d 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -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", diff --git a/tests/integration/test_error_handling_edge_cases.py b/tests/integration/test_error_handling_edge_cases.py index a0612d71..4587fd69 100644 --- a/tests/integration/test_error_handling_edge_cases.py +++ b/tests/integration/test_error_handling_edge_cases.py @@ -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"] diff --git a/tests/integration/test_swap_fee_retry.py b/tests/integration/test_swap_fee_retry.py index 8195a7a8..7caa83b9 100644 --- a/tests/integration/test_swap_fee_retry.py +++ b/tests/integration/test_swap_fee_retry.py @@ -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() diff --git a/tests/integration/test_wallet_topup.py b/tests/integration/test_wallet_topup.py index 091f124c..37d59651 100644 --- a/tests/integration/test_wallet_topup.py +++ b/tests/integration/test_wallet_topup.py @@ -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 diff --git a/tests/unit/test_balance.py b/tests/unit/test_balance.py index 93ab4aea..93ac02b8 100644 --- a/tests/unit/test_balance.py +++ b/tests/unit/test_balance.py @@ -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