mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 12:28:22 +00:00
Merge pull request #677 from Routstr/feat/lightning-error-envelope
feat: typed error envelope for lightning invoice endpoints
This commit is contained in:
+23
-1
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
+128
-21
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user