feat: typed error envelope for lightning invoice endpoints

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.
This commit is contained in:
9qeklajc
2026-08-18 22:09:08 +02:00
parent 252d047f3f
commit a138fcc82f
2 changed files with 106 additions and 10 deletions
+71 -10
View File
@@ -255,6 +255,47 @@ def generate_invoice_id() -> str:
return secrets.token_urlsafe(16) 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) @lightning_router.post("/invoice", response_model=InvoiceCreateResponse)
async def create_invoice( async def create_invoice(
request: InvoiceCreateRequest, request: InvoiceCreateRequest,
@@ -266,16 +307,28 @@ async def create_invoice(
if request.purpose == "topup": if request.purpose == "topup":
if not api_key_token: if not api_key_token:
raise HTTPException( raise _invoice_error(
status_code=401, 401,
detail="Authorization bearer api key is required for topup", "Authorization bearer api key is required for topup",
"invalid_request_error",
"topup_authorization_required",
) )
if not api_key_token.startswith("sk-"): 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:]) topup_api_key = await session.get(ApiKey, api_key_token[3:])
if not topup_api_key: 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: try:
description = f"Routstr {request.purpose} {request.amount_sats} sats" description = f"Routstr {request.purpose} {request.amount_sats} sats"
@@ -334,9 +387,7 @@ async def create_invoice(
except Exception as e: except Exception as e:
logger.error(f"Failed to create Lightning invoice: {e}") logger.error(f"Failed to create Lightning invoice: {e}")
raise HTTPException( raise _invoice_creation_error(e)
status_code=500, detail="Failed to create Lightning invoice"
)
@lightning_router.get( @lightning_router.get(
@@ -348,7 +399,12 @@ async def get_invoice_status(
) -> InvoiceStatusResponse: ) -> InvoiceStatusResponse:
invoice = await session.get(LightningInvoice, invoice_id) invoice = await session.get(LightningInvoice, invoice_id)
if not invoice: 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 definitively_unpaid = False
if _within_settlement_window(invoice, int(time.time())): if _within_settlement_window(invoice, int(time.time())):
@@ -387,7 +443,12 @@ async def recover_invoice(
invoice = result.first() invoice = result.first()
if not invoice: 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 # 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. # the grace window. Holding the bolt11 already proves the caller owns it.
@@ -16,6 +16,7 @@ from httpx import AsyncClient
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from routstr.core.db import ApiKey from routstr.core.db import ApiKey
from routstr.wallet import MintConnectionError
RIP08_PATH = "/lightning/invoice" RIP08_PATH = "/lightning/invoice"
LEGACY_PATH = "/v1/balance/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"}, json={"amount_sats": 100, "purpose": "topup"},
) )
assert resp.status_code == 401 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.integration
@@ -157,6 +161,9 @@ async def test_topup_unknown_api_key_returns_404(
headers={"Authorization": "Bearer sk-deadbeef"}, headers={"Authorization": "Bearer sk-deadbeef"},
) )
assert resp.status_code == 404 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.integration
@@ -169,6 +176,9 @@ async def test_invoice_status_404_for_unknown_id(
base = path.rsplit("/invoice", 1)[0] + "/invoice" base = path.rsplit("/invoice", 1)[0] + "/invoice"
resp = await integration_client.get(f"{base}/does-not-exist/status") resp = await integration_client.get(f"{base}/does-not-exist/status")
assert resp.status_code == 404 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 @pytest.mark.integration
@@ -204,3 +214,28 @@ async def test_authorization_header_overrides_body_api_key(
headers={"Authorization": f"Bearer {seeded_topup_key}"}, headers={"Authorization": f"Bearer {seeded_topup_key}"},
) )
assert resp.status_code == 200, resp.text 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