From b1d43bf722f9ea906deae33771c00ae884aaa1f6 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Fri, 21 Aug 2026 22:01:59 +0200 Subject: [PATCH] fix: version lightning error contract under v2 --- docs/api/errors.md | 24 ++- docs/client/integration.md | 2 +- docs/client/introduction.md | 5 +- docs/client/payments.md | 10 +- routstr/core/main.py | 7 +- routstr/lightning.py | 87 +++++++--- .../test_lightning_invoice_rip08.py | 164 +++++++++++++++--- tests/unit/test_mint_fallback_trust.py | 13 ++ 8 files changed, 255 insertions(+), 57 deletions(-) diff --git a/docs/api/errors.md b/docs/api/errors.md index 95983de8..66595d92 100644 --- a/docs/api/errors.md +++ b/docs/api/errors.md @@ -4,7 +4,7 @@ This guide covers error responses, codes, and handling strategies for the Routst ## Error Response Format -All errors follow a consistent JSON structure: +Versioned endpoints use a structured JSON error object: ```json { @@ -19,6 +19,28 @@ All errors follow a consistent JSON structure: } ``` +### Lightning invoice errors + +The `/v2/lightning/*` endpoints use the structured error object above. In the +HTTP response, `error` is available at the top level and mirrored under +`detail.error`. Clients should branch on `error.code`. + +| Endpoint case | Status | `type` | `code` | +|---------------|--------|--------|--------| +| Top-up without a credential | 401 | `invalid_request_error` | `topup_authorization_required` | +| Top-up with a non-`sk-` credential | 400 | `invalid_request_error` | `topup_invalid_api_key_format` | +| Top-up target key not found | 404 | `invalid_request_error` | `topup_api_key_not_found` | +| Invoice not found during status or recovery | 404 | `invalid_request_error` | `invoice_not_found` | +| Cashu mint rate-limited | 503 | `mint_rate_limited` | `lightning_mint_rate_limited` | +| Cashu mint unreachable | 503 | `mint_unreachable` | `lightning_mint_unreachable` | +| Unexpected invoice creation failure | 500 | `api_error` | `invoice_creation_failed` | + +Request validation failures, including non-positive or excessive amounts, use +FastAPI's standard 422 validation response. Only the 503 mint failures are retryable. Use backoff and honor any mint +cooldown. The compatibility endpoints `/lightning/*` and +`/v1/balance/lightning/*` retain their original string `detail` errors and +legacy status behavior. + ## HTTP Status Codes | Status | Meaning | Common Causes | diff --git a/docs/client/integration.md b/docs/client/integration.md index daf576a9..8f6307a9 100644 --- a/docs/client/integration.md +++ b/docs/client/integration.md @@ -114,7 +114,7 @@ If your session runs out of funds, the API will return a `402` error. } ``` -**Action**: Top up your key using the `/lightning/invoice` (topup purpose) or `/v1/balance/topup` endpoints. +**Action**: Top up your key using the `/v2/lightning/invoice` (topup purpose) or `/v1/balance/topup` endpoints. ### Rate Limiting Routstr passes through rate limits from the upstream provider. Handle `429 Too Many Requests` with standard exponential backoff. diff --git a/docs/client/introduction.md b/docs/client/introduction.md index 2616e7bd..42cba3b4 100644 --- a/docs/client/introduction.md +++ b/docs/client/introduction.md @@ -75,11 +75,12 @@ Visit the node's root page (e.g., [api.routstr.com](https://api.routstr.com)) or Generate an invoice and pay it with any Lightning wallet. ```bash -curl -X POST https://api.routstr.com/lightning/invoice \ +curl -X POST https://api.routstr.com/v2/lightning/invoice \ + -H 'Content-Type: application/json' \ -d '{"amount_sats": 1000, "purpose": "create"}' ``` -*Returns an invoice (`bolt11`) and an ID. Once paid, the status endpoint returns your `api_key`.* +*Returns an invoice (`bolt11`) and an ID. Once paid, `GET /v2/lightning/invoice/{invoice_id}/status` returns your `api_key`.* **Option B: Cashu Token (Best for privacy & devs)** If you have a Cashu wallet, you can copy a token string (`cashuA...`) and use it directly. diff --git a/docs/client/payments.md b/docs/client/payments.md index 93ac35a7..f85c8df2 100644 --- a/docs/client/payments.md +++ b/docs/client/payments.md @@ -15,9 +15,11 @@ To start making requests, you must create a "Balance" (represented by an API Key **Ideal for**: Users connecting from a standard Lightning wallet (Strike, Cash App, WoS). 1. **Request Invoice**: - `POST /lightning/invoice` with `{"amount_sats": 5000, "purpose": "create"}`. + `POST /v2/lightning/invoice` with `{"amount_sats": 5000, "purpose": "create"}`. 2. **Pay Invoice**: User scans and pays the QR code/bolt11 string. -3. **Receive Key**: Routstr detects the payment and issues a new API Key (`sk-...`) pre-loaded with 5,000 sats (5,000,000 msats). +3. **Receive Key**: Poll `GET /v2/lightning/invoice/{invoice_id}/status`. Routstr detects the payment and returns a new API Key (`sk-...`) pre-loaded with 5,000 sats (5,000,000 msats). + +If the invoice ID is lost, recover its status with `POST /v2/lightning/recover` and `{"bolt11": "..."}`. ### Method B: Cashu Token Import @@ -53,10 +55,10 @@ If your balance runs low, you don't need a new key. You can top up the existing ### Via Lightning -`POST /lightning/invoice` with `Authorization: Bearer sk-...` header and body `{"amount_sats": 1000, "purpose": "topup"}`. +`POST /v2/lightning/invoice` with `Authorization: Bearer sk-...` header and body `{"amount_sats": 1000, "purpose": "topup"}`. *Once paid, the funds are added to your existing key.* -> Legacy: the endpoint is also exposed at `/v1/balance/lightning/invoice`, and accepts an `api_key` field in the body as a fallback for older clients. New integrations should use the RIP-08 path with the `Authorization` header. +The v2 endpoints return typed errors with stable `type` and `code` fields. The compatibility endpoints `/lightning/*` and `/v1/balance/lightning/*` remain available with their original status codes and string `detail` errors. The deprecated `api_key` request field is still accepted as a fallback, but new integrations should use v2 with the `Authorization` header. ### Via Cashu diff --git a/routstr/core/main.py b/routstr/core/main.py index 979f5cdb..5ca71c9a 100644 --- a/routstr/core/main.py +++ b/routstr/core/main.py @@ -17,7 +17,11 @@ from ..auth import ( periodic_stale_reservation_sweep, ) from ..balance import balance_router, deprecated_wallet_router -from ..lightning import lightning_router, periodic_invoice_watcher +from ..lightning import ( + lightning_router, + periodic_invoice_watcher, + v2_lightning_router, +) from ..nostr import ( announce_provider, providers_cache_refresher, @@ -422,6 +426,7 @@ app.include_router(models_router) app.include_router(admin_router) app.include_router(balance_router) app.include_router(lightning_router) +app.include_router(v2_lightning_router) app.include_router(deprecated_wallet_router) app.include_router(providers_router) app.include_router(proxy_router) diff --git a/routstr/lightning.py b/routstr/lightning.py index 81e32b5d..98ade521 100644 --- a/routstr/lightning.py +++ b/routstr/lightning.py @@ -8,7 +8,7 @@ from dataclasses import dataclass from typing import Any, AsyncGenerator from cashu.core.base import MintQuoteState -from fastapi import APIRouter, Depends, Header, HTTPException +from fastapi import APIRouter, Depends, Header, HTTPException, Request from pydantic import BaseModel, Field from sqlalchemy.orm.attributes import set_committed_value from sqlmodel import col, select, update @@ -24,6 +24,7 @@ from .core.db import ( from .core.logging import get_logger from .core.settings import settings from .mint import ( + MintCooldownError, is_mint_rate_limited, mint_cooldown_remaining, run_mint_operation, @@ -38,6 +39,8 @@ from .wallet import ( logger = get_logger(__name__) lightning_router = APIRouter(prefix="/lightning") +v2_lightning_router = APIRouter(prefix="/v2/lightning") + # Avoid duplicate work within one process. Cross-process settlement is fenced # by claiming a paid quote before minting and by the final conditional update. @@ -201,6 +204,7 @@ async def _request_mint_with_fallback( candidates = trusted else: candidates = trusted + all_rate_limited = bool(candidates) for mint_url in candidates: cooldown = mint_cooldown_remaining(mint_url) if cooldown > 0: @@ -225,8 +229,11 @@ async def _request_mint_with_fallback( return quote.request, quote.quote, mint_url except Exception as e: tried.append(f"{mint_url}: {type(e).__name__}") - if not is_mint_connection_error(e) and not is_mint_rate_limited(e): + rate_limited = is_mint_rate_limited(e) + if not is_mint_connection_error(e) and not rate_limited: raise + if not rate_limited: + all_rate_limited = False logger.warning( "request_mint failed, trying fallback mint", extra={ @@ -236,6 +243,11 @@ async def _request_mint_with_fallback( }, ) continue + if all_rate_limited: + retry_after = max( + (mint_cooldown_remaining(mint) for mint in candidates), default=0.0 + ) + raise MintCooldownError("all configured mints", retry_after) raise MintConnectionError(f"All mints failed for request_mint: {tried}") @@ -255,24 +267,41 @@ def generate_invoice_id() -> str: return secrets.token_urlsafe(16) +def _uses_v2_errors(request: Request) -> bool: + return request.scope["path"].startswith("/v2/lightning/") + + def _invoice_error( - status_code: int, message: str, error_type: str, code: str + status_code: int, + message: str, + error_type: str, + code: str, + *, + structured: bool, + legacy_message: str | None = None, ) -> HTTPException: - """Build a client-facing error using the same envelope as the proxy paths.""" - return HTTPException( - status_code=status_code, - detail={"error": {"message": message, "type": error_type, "code": code}}, - ) + """Build either the legacy string detail or the v2 typed envelope.""" + detail: str | dict[str, dict[str, str]] + if structured: + detail = {"error": {"message": message, "type": error_type, "code": code}} + else: + detail = legacy_message or message + return HTTPException(status_code=status_code, detail=detail) -def _invoice_creation_error(error: Exception) -> HTTPException: - """Map an invoice-creation failure to its specific client-facing error.""" +def _invoice_creation_error(error: Exception, *, structured: bool) -> HTTPException: + """Map an invoice-creation failure without changing legacy endpoint behavior.""" + if not structured: + return HTTPException( + status_code=500, detail="Failed to create Lightning invoice" + ) if is_mint_rate_limited(error): return _invoice_error( 503, "Cashu mint rate-limited; retry after cooldown", "mint_rate_limited", "lightning_mint_rate_limited", + structured=True, ) if is_mint_connection_error(error): return _invoice_error( @@ -280,28 +309,26 @@ def _invoice_creation_error(error: Exception) -> HTTPException: "Cashu mint is unreachable; no Lightning quote could be requested", "mint_unreachable", "lightning_mint_unreachable", - ) - if isinstance(error, ValueError): - return _invoice_error( - 400, - "Invalid invoice amount", - "invalid_request_error", - "invalid_invoice_amount", + structured=True, ) return _invoice_error( 500, "Failed to create Lightning invoice", "api_error", "invoice_creation_failed", + structured=True, ) +@v2_lightning_router.post("/invoice", response_model=InvoiceCreateResponse) @lightning_router.post("/invoice", response_model=InvoiceCreateResponse) async def create_invoice( request: InvoiceCreateRequest, authorization: str | None = Header(default=None), session: AsyncSession = Depends(get_session), + structured_errors: bool = Depends(_uses_v2_errors), ) -> InvoiceCreateResponse: + structured_errors = structured_errors is True api_key_token = _extract_bearer_api_key(authorization) or request.api_key topup_api_key: ApiKey | None = None @@ -312,6 +339,7 @@ async def create_invoice( "Authorization bearer api key is required for topup", "invalid_request_error", "topup_authorization_required", + structured=structured_errors, ) if not api_key_token.startswith("sk-"): raise _invoice_error( @@ -319,6 +347,8 @@ async def create_invoice( "Invalid API key format. Expected an 'sk-...' API key.", "invalid_request_error", "topup_invalid_api_key_format", + structured=structured_errors, + legacy_message="Invalid API key format", ) topup_api_key = await session.get(ApiKey, api_key_token[3:]) @@ -328,6 +358,7 @@ async def create_invoice( "API key not found", "invalid_request_error", "topup_api_key_not_found", + structured=structured_errors, ) try: @@ -338,9 +369,7 @@ async def create_invoice( # A key's liabilities are attributed to a single refund mint. Keep # top-up collateral on that same mint so balances and payouts cannot # misclassify funds held by another mint as owner profit. - allowed_mints = [ - topup_api_key.refund_mint_url or settings.primary_mint - ] + allowed_mints = [topup_api_key.refund_mint_url or settings.primary_mint] bolt11, payment_hash, mint_url = await generate_lightning_invoice( request.amount_sats, description, allowed_mints=allowed_mints ) @@ -387,15 +416,19 @@ async def create_invoice( except Exception as e: logger.error(f"Failed to create Lightning invoice: {e}") - raise _invoice_creation_error(e) + raise _invoice_creation_error(e, structured=structured_errors) +@v2_lightning_router.get( + "/invoice/{invoice_id}/status", response_model=InvoiceStatusResponse +) @lightning_router.get( "/invoice/{invoice_id}/status", response_model=InvoiceStatusResponse ) async def get_invoice_status( invoice_id: str, session: AsyncSession = Depends(get_session), + structured_errors: bool = Depends(_uses_v2_errors), ) -> InvoiceStatusResponse: invoice = await session.get(LightningInvoice, invoice_id) if not invoice: @@ -404,6 +437,7 @@ async def get_invoice_status( "Invoice not found", "invalid_request_error", "invoice_not_found", + structured=structured_errors is True, ) definitively_unpaid = False @@ -432,10 +466,12 @@ async def get_invoice_status( ) +@v2_lightning_router.post("/recover", response_model=InvoiceStatusResponse) @lightning_router.post("/recover", response_model=InvoiceStatusResponse) async def recover_invoice( request: InvoiceRecoverRequest, session: AsyncSession = Depends(get_session), + structured_errors: bool = Depends(_uses_v2_errors), ) -> InvoiceStatusResponse: result = await session.exec( select(LightningInvoice).where(LightningInvoice.bolt11 == request.bolt11) @@ -448,6 +484,7 @@ async def recover_invoice( "Invoice not found", "invalid_request_error", "invoice_not_found", + structured=structured_errors is True, ) # Recovery is the last remedy for a payment we never observed, so it ignores @@ -614,9 +651,7 @@ async def check_invoice_payment( "invoice_id": settlement.id, "amount_sats": settlement.amount_sats, "purpose": settlement.purpose, - "api_key_hash": api_key_hash[:8] + "..." - if api_key_hash - else None, + "api_key_hash": api_key_hash[:8] + "..." if api_key_hash else None, }, ) return False @@ -638,9 +673,7 @@ async def check_invoice_payment( ) await state_session.commit() if pending.rowcount == 1: - _publish_invoice_value( - invoice, "status", "settlement_pending" - ) + _publish_invoice_value(invoice, "status", "settlement_pending") except Exception as state_error: logger.critical( "Paid invoice reconciliation state could not be persisted", diff --git a/tests/integration/test_lightning_invoice_rip08.py b/tests/integration/test_lightning_invoice_rip08.py index 261da887..76ac0115 100644 --- a/tests/integration/test_lightning_invoice_rip08.py +++ b/tests/integration/test_lightning_invoice_rip08.py @@ -1,9 +1,4 @@ -"""RIP-08 lightning invoice endpoint tests. - -Verifies both the spec-compliant path (`POST /lightning/invoice` with -`Authorization: Bearer sk-...`) and the legacy path -(`POST /v1/balance/lightning/invoice` with `api_key` in body). -""" +"""Lightning invoice endpoint compatibility and v2 contract tests.""" from __future__ import annotations @@ -20,6 +15,9 @@ from routstr.wallet import MintConnectionError RIP08_PATH = "/lightning/invoice" LEGACY_PATH = "/v1/balance/lightning/invoice" +V2_PATH = "/v2/lightning/invoice" +COMPATIBILITY_PATHS = [RIP08_PATH, LEGACY_PATH] +ALL_PATHS = [*COMPATIBILITY_PATHS, V2_PATH] @pytest_asyncio.fixture @@ -64,13 +62,13 @@ async def seeded_topup_key(integration_session: AsyncSession) -> str: @pytest.mark.integration @pytest.mark.asyncio -@pytest.mark.parametrize("path", [RIP08_PATH, LEGACY_PATH]) +@pytest.mark.parametrize("path", ALL_PATHS) async def test_create_invoice_purpose_create( integration_client: AsyncClient, patch_invoice_generation: Any, path: str, ) -> None: - """`purpose=create` works on both paths and requires no auth.""" + """`purpose=create` works on every path and requires no auth.""" resp = await integration_client.post( path, json={"amount_sats": 1000, "purpose": "create"}, @@ -85,7 +83,7 @@ async def test_create_invoice_purpose_create( @pytest.mark.integration @pytest.mark.asyncio -@pytest.mark.parametrize("path", [RIP08_PATH, LEGACY_PATH]) +@pytest.mark.parametrize("path", ALL_PATHS) async def test_topup_with_authorization_header( integration_client: AsyncClient, patch_invoice_generation: Any, @@ -108,14 +106,14 @@ async def test_topup_with_authorization_header( @pytest.mark.integration @pytest.mark.asyncio -@pytest.mark.parametrize("path", [RIP08_PATH, LEGACY_PATH]) +@pytest.mark.parametrize("path", ALL_PATHS) async def test_topup_with_legacy_api_key_in_body( integration_client: AsyncClient, patch_invoice_generation: Any, seeded_topup_key: str, path: str, ) -> None: - """Legacy: topup with `api_key` in body still accepted on both paths.""" + """The deprecated body `api_key` remains accepted on every path.""" resp = await integration_client.post( path, json={ @@ -130,18 +128,33 @@ async def test_topup_with_legacy_api_key_in_body( @pytest.mark.integration @pytest.mark.asyncio -@pytest.mark.parametrize("path", [RIP08_PATH, LEGACY_PATH]) -async def test_topup_missing_auth_returns_401( +@pytest.mark.parametrize("path", COMPATIBILITY_PATHS) +async def test_compatibility_topup_missing_auth_keeps_string_error( integration_client: AsyncClient, patch_invoice_generation: Any, path: str, ) -> None: - """Topup without any credential is rejected on both paths.""" resp = await integration_client.post( path, json={"amount_sats": 100, "purpose": "topup"}, ) assert resp.status_code == 401 + assert resp.json()["detail"] == ( + "Authorization bearer api key is required for topup" + ) + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_v2_topup_missing_auth_returns_typed_error( + integration_client: AsyncClient, + patch_invoice_generation: Any, +) -> None: + resp = await integration_client.post( + V2_PATH, + json={"amount_sats": 100, "purpose": "topup"}, + ) + assert resp.status_code == 401 error = resp.json()["detail"]["error"] assert error["type"] == "invalid_request_error" assert error["code"] == "topup_authorization_required" @@ -149,8 +162,47 @@ async def test_topup_missing_auth_returns_401( @pytest.mark.integration @pytest.mark.asyncio -@pytest.mark.parametrize("path", [RIP08_PATH, LEGACY_PATH]) -async def test_topup_unknown_api_key_returns_404( +@pytest.mark.parametrize( + "path,expected_detail", + [ + (RIP08_PATH, "Invalid API key format"), + (LEGACY_PATH, "Invalid API key format"), + ], +) +async def test_compatibility_invalid_api_key_format_keeps_original_message( + integration_client: AsyncClient, + path: str, + expected_detail: str, +) -> None: + resp = await integration_client.post( + path, + json={"amount_sats": 100, "purpose": "topup"}, + headers={"Authorization": "Bearer invalid"}, + ) + assert resp.status_code == 400 + assert resp.json()["detail"] == expected_detail + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_v2_invalid_api_key_format_returns_typed_error( + integration_client: AsyncClient, +) -> None: + resp = await integration_client.post( + V2_PATH, + json={"amount_sats": 100, "purpose": "topup"}, + headers={"Authorization": "Bearer invalid"}, + ) + assert resp.status_code == 400 + error = resp.json()["detail"]["error"] + assert error["type"] == "invalid_request_error" + assert error["code"] == "topup_invalid_api_key_format" + + +@pytest.mark.integration +@pytest.mark.asyncio +@pytest.mark.parametrize("path", COMPATIBILITY_PATHS) +async def test_compatibility_unknown_api_key_keeps_string_error( integration_client: AsyncClient, patch_invoice_generation: Any, path: str, @@ -161,6 +213,21 @@ async def test_topup_unknown_api_key_returns_404( headers={"Authorization": "Bearer sk-deadbeef"}, ) assert resp.status_code == 404 + assert resp.json()["detail"] == "API key not found" + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_v2_unknown_api_key_returns_typed_error( + integration_client: AsyncClient, + patch_invoice_generation: Any, +) -> None: + resp = await integration_client.post( + V2_PATH, + json={"amount_sats": 100, "purpose": "topup"}, + headers={"Authorization": "Bearer sk-deadbeef"}, + ) + assert resp.status_code == 404 error = resp.json()["detail"]["error"] assert error["type"] == "invalid_request_error" assert error["code"] == "topup_api_key_not_found" @@ -168,13 +235,22 @@ async def test_topup_unknown_api_key_returns_404( @pytest.mark.integration @pytest.mark.asyncio -@pytest.mark.parametrize("path", [RIP08_PATH, LEGACY_PATH]) -async def test_invoice_status_404_for_unknown_id( +@pytest.mark.parametrize("path", COMPATIBILITY_PATHS) +async def test_compatibility_status_404_keeps_string_error( integration_client: AsyncClient, path: str, ) -> None: - base = path.rsplit("/invoice", 1)[0] + "/invoice" - resp = await integration_client.get(f"{base}/does-not-exist/status") + resp = await integration_client.get(f"{path}/does-not-exist/status") + assert resp.status_code == 404 + assert resp.json()["detail"] == "Invoice not found" + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_v2_status_404_returns_typed_error( + integration_client: AsyncClient, +) -> None: + resp = await integration_client.get(f"{V2_PATH}/does-not-exist/status") assert resp.status_code == 404 error = resp.json()["detail"]["error"] assert error["type"] == "invalid_request_error" @@ -235,7 +311,53 @@ async def test_create_invoice_maps_mint_failures( "routstr.lightning.generate_lightning_invoice", side_effect=error, ): - resp = await integration_client.post(RIP08_PATH, json={"amount_sats": 100}) + resp = await integration_client.post(V2_PATH, json={"amount_sats": 100}) assert resp.status_code == status assert resp.json()["detail"]["error"]["code"] == code + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_compatibility_create_failure_keeps_generic_error( + integration_client: AsyncClient, +) -> None: + with patch( + "routstr.lightning.generate_lightning_invoice", + side_effect=MintConnectionError("all mints failed"), + ): + resp = await integration_client.post(RIP08_PATH, json={"amount_sats": 100}) + + assert resp.status_code == 500 + assert resp.json()["detail"] == "Failed to create Lightning invoice" + + +@pytest.mark.integration +@pytest.mark.asyncio +@pytest.mark.parametrize( + "path,expected_detail", + [ + ("/lightning/recover", "Invoice not found"), + ("/v1/balance/lightning/recover", "Invoice not found"), + ], +) +async def test_compatibility_recover_404_keeps_string_error( + integration_client: AsyncClient, + path: str, + expected_detail: str, +) -> None: + resp = await integration_client.post(path, json={"bolt11": "unknown"}) + assert resp.status_code == 404 + assert resp.json()["detail"] == expected_detail + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_v2_recover_404_returns_typed_error( + integration_client: AsyncClient, +) -> None: + resp = await integration_client.post( + "/v2/lightning/recover", json={"bolt11": "unknown"} + ) + assert resp.status_code == 404 + assert resp.json()["detail"]["error"]["code"] == "invoice_not_found" diff --git a/tests/unit/test_mint_fallback_trust.py b/tests/unit/test_mint_fallback_trust.py index f7809458..b72314fc 100644 --- a/tests/unit/test_mint_fallback_trust.py +++ b/tests/unit/test_mint_fallback_trust.py @@ -6,6 +6,7 @@ import pytest from routstr.core.settings import settings from routstr.lightning import _request_mint_with_fallback +from routstr.mint import MintCooldownError, is_mint_rate_limited TRUSTED = "https://good-mint.example.com" UNTRUSTED = "https://removed-mint.example.com" @@ -48,3 +49,15 @@ async def test_trusted_allowed_mints_are_used_verbatim() -> None: await _request_mint_with_fallback(10, allowed_mints=[TRUSTED]) assert attempted == [TRUSTED] + + +async def test_all_cooling_down_mints_preserve_rate_limit_error() -> None: + with ( + patch.object(settings, "primary_mint", TRUSTED), + patch.object(settings, "cashu_mints", [TRUSTED]), + patch("routstr.lightning.mint_cooldown_remaining", return_value=30.0), + ): + with pytest.raises(MintCooldownError) as caught: + await _request_mint_with_fallback(10) + + assert is_mint_rate_limited(caught.value)