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