diff --git a/docs/api/errors.md b/docs/api/errors.md index 293ddb1a..82bb5be1 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 the +`Retry-After` header when present. 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 c108c1a0..cd118b13 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 @@ -54,10 +56,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/exceptions.py b/routstr/core/exceptions.py index 2fc34bb6..fe1a2ef3 100644 --- a/routstr/core/exceptions.py +++ b/routstr/core/exceptions.py @@ -92,7 +92,8 @@ async def http_exception_handler(request: Request, exc: Exception) -> JSONRespon content = {"detail": detail} content["request_id"] = request_id - return JSONResponse(status_code=status_code, content=content) + headers = getattr(exc, "headers", None) + return JSONResponse(status_code=status_code, content=content, headers=headers) def json_compliant(value: object) -> object: diff --git a/routstr/core/main.py b/routstr/core/main.py index f6774358..5ce22d22 100644 --- a/routstr/core/main.py +++ b/routstr/core/main.py @@ -18,7 +18,11 @@ from ..auth import ( ) from ..balance import balance_router, deprecated_wallet_router from ..cashu_compat import install_cashu_httpx_shim -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, @@ -436,6 +440,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 e45da084..b3ece68f 100644 --- a/routstr/lightning.py +++ b/routstr/lightning.py @@ -1,5 +1,6 @@ import asyncio import hashlib +import math import re import secrets import time @@ -8,7 +9,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 +25,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 +40,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. @@ -195,6 +199,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: @@ -226,8 +231,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={ @@ -237,6 +245,9 @@ async def _request_mint_with_fallback( }, ) continue + if all_rate_limited: + slowest = max(candidates, key=mint_cooldown_remaining) + raise MintCooldownError(slowest, mint_cooldown_remaining(slowest)) raise MintConnectionError(f"All mints failed for request_mint: {tried}") @@ -256,27 +267,113 @@ 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, + *, + structured: bool, + legacy_message: str | None = None, + headers: dict[str, str] | None = None, +) -> HTTPException: + """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, headers=headers) + + +def _mint_retry_after(error: BaseException) -> int | None: + current: BaseException | None = error + seen: set[int] = set() + while current is not None and id(current) not in seen: + seen.add(id(current)) + if isinstance(current, MintCooldownError): + return math.ceil(current.retry_after_seconds) + current = current.__cause__ or current.__context__ + return None + + +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): + retry_after = _mint_retry_after(error) + return _invoice_error( + 503, + "Cashu mint rate-limited; retry after cooldown", + "mint_rate_limited", + "lightning_mint_rate_limited", + structured=True, + headers={"Retry-After": str(retry_after)} if retry_after else None, + ) + if is_mint_connection_error(error): + return _invoice_error( + 503, + "Cashu mint is unreachable; no Lightning quote could be requested", + "mint_unreachable", + "lightning_mint_unreachable", + 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 if request.purpose == "topup": if not api_key_token: - raise HTTPException( - status_code=401, - detail="Authorization bearer api key is required for topup", + raise _invoice_error( + 401, + "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 HTTPException(status_code=400, detail="Invalid API key format") + raise _invoice_error( + 400, + "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:]) if not topup_api_key: - raise HTTPException(status_code=404, detail="API key not found") + raise _invoice_error( + 404, + "API key not found", + "invalid_request_error", + "topup_api_key_not_found", + structured=structured_errors, + ) try: description = f"Routstr {request.purpose} {request.amount_sats} sats" @@ -286,9 +383,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 ) @@ -333,21 +428,29 @@ async def create_invoice( except Exception as e: logger.error(f"Failed to create Lightning invoice: {e}") - raise HTTPException( - status_code=500, detail="Failed to create Lightning invoice" - ) + 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: - raise HTTPException(status_code=404, detail="Invoice not found") + raise _invoice_error( + 404, + "Invoice not found", + "invalid_request_error", + "invoice_not_found", + structured=structured_errors is True, + ) definitively_unpaid = False if _within_settlement_window(invoice, int(time.time())): @@ -375,10 +478,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) @@ -386,7 +491,13 @@ async def recover_invoice( invoice = result.first() if not invoice: - raise HTTPException(status_code=404, detail="Invoice not found") + raise _invoice_error( + 404, + "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 # the grace window. Holding the bolt11 already proves the caller owns it. @@ -552,9 +663,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 @@ -576,9 +685,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 faba77c7..d3915e0e 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 @@ -16,9 +11,14 @@ from httpx import AsyncClient from sqlmodel.ext.asyncio.session import AsyncSession from routstr.core.db import ApiKey +from routstr.mint import MintCooldownError +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 @@ -63,13 +63,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"}, @@ -84,7 +84,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, @@ -107,14 +107,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={ @@ -129,24 +129,81 @@ 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 -@pytest.mark.parametrize("path", [RIP08_PATH, LEGACY_PATH]) -async def test_topup_unknown_api_key_returns_404( +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" + + +@pytest.mark.integration +@pytest.mark.asyncio +@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, @@ -157,18 +214,48 @@ 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 -@pytest.mark.parametrize("path", [RIP08_PATH, LEGACY_PATH]) -async def test_invoice_status_404_for_unknown_id( +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" + + +@pytest.mark.integration +@pytest.mark.asyncio +@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" + assert error["code"] == "invoice_not_found" @pytest.mark.integration @@ -204,3 +291,90 @@ async def test_authorization_header_overrides_body_api_key( headers={"Authorization": f"Bearer {seeded_topup_key}"}, ) assert resp.status_code == 200, resp.text + + +@pytest.mark.integration +@pytest.mark.asyncio +@pytest.mark.parametrize( + "error,status,code", + [ + (MintConnectionError("all mints failed"), 503, "lightning_mint_unreachable"), + (RuntimeError("boom"), 500, "invoice_creation_failed"), + ], +) +async def test_create_invoice_maps_mint_failures( + integration_client: AsyncClient, + error: Exception, + status: int, + code: str, +) -> None: + with patch( + "routstr.lightning.generate_lightning_invoice", + side_effect=error, + ): + 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_v2_create_invoice_rate_limited_sets_retry_after( + integration_client: AsyncClient, +) -> None: + with patch( + "routstr.lightning.generate_lightning_invoice", + side_effect=MintCooldownError("https://mint.example.com", 12.4), + ): + resp = await integration_client.post(V2_PATH, json={"amount_sats": 100}) + + assert resp.status_code == 503 + assert resp.headers["Retry-After"] == "13" + assert resp.json()["detail"]["error"]["code"] == "lightning_mint_rate_limited" + + +@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 5f2a2ab6..6eec7111 100644 --- a/tests/unit/test_mint_fallback_trust.py +++ b/tests/unit/test_mint_fallback_trust.py @@ -7,6 +7,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" @@ -66,3 +67,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)