mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 20:28:23 +00:00
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:
+71
-10
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user