From a138fcc82fc5c1a2d481fda72dec12223b8492e3 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Tue, 18 Aug 2026 22:03:59 +0200 Subject: [PATCH] feat: typed error envelope for lightning invoice endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lightning invoice errors now use the {"error": {message, type, code}} envelope the proxy and chat-completion paths already return, with codes specific to this path: topup_authorization_required, topup_invalid_api_key_format, topup_api_key_not_found, invoice_not_found, invalid_invoice_amount, invoice_creation_failed. Invoice creation no longer collapses every failure into a 500 — an unreachable or rate-limited mint now returns 503 with lightning_mint_unreachable / lightning_mint_rate_limited, and a bad amount returns 400. Note: `detail` changes from a string to an object on these endpoints, so clients reading it as text (including ui/lib/api/errors.ts) need updating. --- routstr/lightning.py | 81 ++++++++++++++++--- .../test_lightning_invoice_rip08.py | 35 ++++++++ 2 files changed, 106 insertions(+), 10 deletions(-) diff --git a/routstr/lightning.py b/routstr/lightning.py index df48756c..81e32b5d 100644 --- a/routstr/lightning.py +++ b/routstr/lightning.py @@ -255,6 +255,47 @@ def generate_invoice_id() -> str: return secrets.token_urlsafe(16) +def _invoice_error( + status_code: int, message: str, error_type: str, code: str +) -> 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}}, + ) + + +def _invoice_creation_error(error: Exception) -> HTTPException: + """Map an invoice-creation failure to its specific client-facing error.""" + if is_mint_rate_limited(error): + return _invoice_error( + 503, + "Cashu mint rate-limited; retry after cooldown", + "mint_rate_limited", + "lightning_mint_rate_limited", + ) + 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", + ) + if isinstance(error, ValueError): + return _invoice_error( + 400, + "Invalid invoice amount", + "invalid_request_error", + "invalid_invoice_amount", + ) + return _invoice_error( + 500, + "Failed to create Lightning invoice", + "api_error", + "invoice_creation_failed", + ) + + @lightning_router.post("/invoice", response_model=InvoiceCreateResponse) async def create_invoice( request: InvoiceCreateRequest, @@ -266,16 +307,28 @@ async def create_invoice( 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", ) 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", + ) 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", + ) try: description = f"Routstr {request.purpose} {request.amount_sats} sats" @@ -334,9 +387,7 @@ 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) @lightning_router.get( @@ -348,7 +399,12 @@ async def get_invoice_status( ) -> 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", + ) definitively_unpaid = False if _within_settlement_window(invoice, int(time.time())): @@ -387,7 +443,12 @@ 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", + ) # 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. diff --git a/tests/integration/test_lightning_invoice_rip08.py b/tests/integration/test_lightning_invoice_rip08.py index faba77c7..261da887 100644 --- a/tests/integration/test_lightning_invoice_rip08.py +++ b/tests/integration/test_lightning_invoice_rip08.py @@ -16,6 +16,7 @@ from httpx import AsyncClient from sqlmodel.ext.asyncio.session import AsyncSession from routstr.core.db import ApiKey +from routstr.wallet import MintConnectionError RIP08_PATH = "/lightning/invoice" LEGACY_PATH = "/v1/balance/lightning/invoice" @@ -141,6 +142,9 @@ async def test_topup_missing_auth_returns_401( 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 @@ -157,6 +161,9 @@ async def test_topup_unknown_api_key_returns_404( 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 @@ -169,6 +176,9 @@ async def test_invoice_status_404_for_unknown_id( base = path.rsplit("/invoice", 1)[0] + "/invoice" resp = await integration_client.get(f"{base}/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 +214,28 @@ 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(RIP08_PATH, json={"amount_sats": 100}) + + assert resp.status_code == status + assert resp.json()["detail"]["error"]["code"] == code