fix: version lightning error contract under v2

This commit is contained in:
9qeklajc
2026-08-21 22:01:59 +02:00
parent a138fcc82f
commit b1d43bf722
8 changed files with 255 additions and 57 deletions
+23 -1
View File
@@ -4,7 +4,7 @@ This guide covers error responses, codes, and handling strategies for the Routst
## Error Response Format ## Error Response Format
All errors follow a consistent JSON structure: Versioned endpoints use a structured JSON error object:
```json ```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 any mint
cooldown. The compatibility endpoints `/lightning/*` and
`/v1/balance/lightning/*` retain their original string `detail` errors and
legacy status behavior.
## HTTP Status Codes ## HTTP Status Codes
| Status | Meaning | Common Causes | | Status | Meaning | Common Causes |
+1 -1
View File
@@ -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 ### Rate Limiting
Routstr passes through rate limits from the upstream provider. Handle `429 Too Many Requests` with standard exponential backoff. Routstr passes through rate limits from the upstream provider. Handle `429 Too Many Requests` with standard exponential backoff.
+3 -2
View File
@@ -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. Generate an invoice and pay it with any Lightning wallet.
```bash ```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"}' -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)** **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. If you have a Cashu wallet, you can copy a token string (`cashuA...`) and use it directly.
+6 -4
View File
@@ -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). **Ideal for**: Users connecting from a standard Lightning wallet (Strike, Cash App, WoS).
1. **Request Invoice**: 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. 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 ### Method B: Cashu Token Import
@@ -53,10 +55,10 @@ If your balance runs low, you don't need a new key. You can top up the existing
### Via Lightning ### 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.* *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 ### Via Cashu
+6 -1
View File
@@ -17,7 +17,11 @@ from ..auth import (
periodic_stale_reservation_sweep, periodic_stale_reservation_sweep,
) )
from ..balance import balance_router, deprecated_wallet_router from ..balance import balance_router, deprecated_wallet_router
from ..lightning import lightning_router, periodic_invoice_watcher from ..lightning import (
lightning_router,
periodic_invoice_watcher,
v2_lightning_router,
)
from ..nostr import ( from ..nostr import (
announce_provider, announce_provider,
providers_cache_refresher, providers_cache_refresher,
@@ -422,6 +426,7 @@ app.include_router(models_router)
app.include_router(admin_router) app.include_router(admin_router)
app.include_router(balance_router) app.include_router(balance_router)
app.include_router(lightning_router) app.include_router(lightning_router)
app.include_router(v2_lightning_router)
app.include_router(deprecated_wallet_router) app.include_router(deprecated_wallet_router)
app.include_router(providers_router) app.include_router(providers_router)
app.include_router(proxy_router) app.include_router(proxy_router)
+60 -27
View File
@@ -8,7 +8,7 @@ from dataclasses import dataclass
from typing import Any, AsyncGenerator from typing import Any, AsyncGenerator
from cashu.core.base import MintQuoteState 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 pydantic import BaseModel, Field
from sqlalchemy.orm.attributes import set_committed_value from sqlalchemy.orm.attributes import set_committed_value
from sqlmodel import col, select, update from sqlmodel import col, select, update
@@ -24,6 +24,7 @@ from .core.db import (
from .core.logging import get_logger from .core.logging import get_logger
from .core.settings import settings from .core.settings import settings
from .mint import ( from .mint import (
MintCooldownError,
is_mint_rate_limited, is_mint_rate_limited,
mint_cooldown_remaining, mint_cooldown_remaining,
run_mint_operation, run_mint_operation,
@@ -38,6 +39,8 @@ from .wallet import (
logger = get_logger(__name__) logger = get_logger(__name__)
lightning_router = APIRouter(prefix="/lightning") lightning_router = APIRouter(prefix="/lightning")
v2_lightning_router = APIRouter(prefix="/v2/lightning")
# Avoid duplicate work within one process. Cross-process settlement is fenced # Avoid duplicate work within one process. Cross-process settlement is fenced
# by claiming a paid quote before minting and by the final conditional update. # by claiming a paid quote before minting and by the final conditional update.
@@ -201,6 +204,7 @@ async def _request_mint_with_fallback(
candidates = trusted candidates = trusted
else: else:
candidates = trusted candidates = trusted
all_rate_limited = bool(candidates)
for mint_url in candidates: for mint_url in candidates:
cooldown = mint_cooldown_remaining(mint_url) cooldown = mint_cooldown_remaining(mint_url)
if cooldown > 0: if cooldown > 0:
@@ -225,8 +229,11 @@ async def _request_mint_with_fallback(
return quote.request, quote.quote, mint_url return quote.request, quote.quote, mint_url
except Exception as e: except Exception as e:
tried.append(f"{mint_url}: {type(e).__name__}") 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 raise
if not rate_limited:
all_rate_limited = False
logger.warning( logger.warning(
"request_mint failed, trying fallback mint", "request_mint failed, trying fallback mint",
extra={ extra={
@@ -236,6 +243,11 @@ async def _request_mint_with_fallback(
}, },
) )
continue continue
if all_rate_limited:
retry_after = max(
(mint_cooldown_remaining(mint) for mint in candidates), default=0.0
)
raise MintCooldownError("all configured mints", retry_after)
raise MintConnectionError(f"All mints failed for request_mint: {tried}") raise MintConnectionError(f"All mints failed for request_mint: {tried}")
@@ -255,24 +267,41 @@ def generate_invoice_id() -> str:
return secrets.token_urlsafe(16) return secrets.token_urlsafe(16)
def _uses_v2_errors(request: Request) -> bool:
return request.scope["path"].startswith("/v2/lightning/")
def _invoice_error( def _invoice_error(
status_code: int, message: str, error_type: str, code: str status_code: int,
message: str,
error_type: str,
code: str,
*,
structured: bool,
legacy_message: str | None = None,
) -> HTTPException: ) -> HTTPException:
"""Build a client-facing error using the same envelope as the proxy paths.""" """Build either the legacy string detail or the v2 typed envelope."""
return HTTPException( detail: str | dict[str, dict[str, str]]
status_code=status_code, if structured:
detail={"error": {"message": message, "type": error_type, "code": code}}, detail = {"error": {"message": message, "type": error_type, "code": code}}
) else:
detail = legacy_message or message
return HTTPException(status_code=status_code, detail=detail)
def _invoice_creation_error(error: Exception) -> HTTPException: def _invoice_creation_error(error: Exception, *, structured: bool) -> HTTPException:
"""Map an invoice-creation failure to its specific client-facing error.""" """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): if is_mint_rate_limited(error):
return _invoice_error( return _invoice_error(
503, 503,
"Cashu mint rate-limited; retry after cooldown", "Cashu mint rate-limited; retry after cooldown",
"mint_rate_limited", "mint_rate_limited",
"lightning_mint_rate_limited", "lightning_mint_rate_limited",
structured=True,
) )
if is_mint_connection_error(error): if is_mint_connection_error(error):
return _invoice_error( return _invoice_error(
@@ -280,28 +309,26 @@ def _invoice_creation_error(error: Exception) -> HTTPException:
"Cashu mint is unreachable; no Lightning quote could be requested", "Cashu mint is unreachable; no Lightning quote could be requested",
"mint_unreachable", "mint_unreachable",
"lightning_mint_unreachable", "lightning_mint_unreachable",
) structured=True,
if isinstance(error, ValueError):
return _invoice_error(
400,
"Invalid invoice amount",
"invalid_request_error",
"invalid_invoice_amount",
) )
return _invoice_error( return _invoice_error(
500, 500,
"Failed to create Lightning invoice", "Failed to create Lightning invoice",
"api_error", "api_error",
"invoice_creation_failed", "invoice_creation_failed",
structured=True,
) )
@v2_lightning_router.post("/invoice", response_model=InvoiceCreateResponse)
@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,
authorization: str | None = Header(default=None), authorization: str | None = Header(default=None),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
structured_errors: bool = Depends(_uses_v2_errors),
) -> InvoiceCreateResponse: ) -> InvoiceCreateResponse:
structured_errors = structured_errors is True
api_key_token = _extract_bearer_api_key(authorization) or request.api_key api_key_token = _extract_bearer_api_key(authorization) or request.api_key
topup_api_key: ApiKey | None = None topup_api_key: ApiKey | None = None
@@ -312,6 +339,7 @@ async def create_invoice(
"Authorization bearer api key is required for topup", "Authorization bearer api key is required for topup",
"invalid_request_error", "invalid_request_error",
"topup_authorization_required", "topup_authorization_required",
structured=structured_errors,
) )
if not api_key_token.startswith("sk-"): if not api_key_token.startswith("sk-"):
raise _invoice_error( raise _invoice_error(
@@ -319,6 +347,8 @@ async def create_invoice(
"Invalid API key format. Expected an 'sk-...' API key.", "Invalid API key format. Expected an 'sk-...' API key.",
"invalid_request_error", "invalid_request_error",
"topup_invalid_api_key_format", "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:]) topup_api_key = await session.get(ApiKey, api_key_token[3:])
@@ -328,6 +358,7 @@ async def create_invoice(
"API key not found", "API key not found",
"invalid_request_error", "invalid_request_error",
"topup_api_key_not_found", "topup_api_key_not_found",
structured=structured_errors,
) )
try: try:
@@ -338,9 +369,7 @@ async def create_invoice(
# A key's liabilities are attributed to a single refund mint. Keep # A key's liabilities are attributed to a single refund mint. Keep
# top-up collateral on that same mint so balances and payouts cannot # top-up collateral on that same mint so balances and payouts cannot
# misclassify funds held by another mint as owner profit. # misclassify funds held by another mint as owner profit.
allowed_mints = [ allowed_mints = [topup_api_key.refund_mint_url or settings.primary_mint]
topup_api_key.refund_mint_url or settings.primary_mint
]
bolt11, payment_hash, mint_url = await generate_lightning_invoice( bolt11, payment_hash, mint_url = await generate_lightning_invoice(
request.amount_sats, description, allowed_mints=allowed_mints request.amount_sats, description, allowed_mints=allowed_mints
) )
@@ -387,15 +416,19 @@ 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 _invoice_creation_error(e) raise _invoice_creation_error(e, structured=structured_errors)
@v2_lightning_router.get(
"/invoice/{invoice_id}/status", response_model=InvoiceStatusResponse
)
@lightning_router.get( @lightning_router.get(
"/invoice/{invoice_id}/status", response_model=InvoiceStatusResponse "/invoice/{invoice_id}/status", response_model=InvoiceStatusResponse
) )
async def get_invoice_status( async def get_invoice_status(
invoice_id: str, invoice_id: str,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
structured_errors: bool = Depends(_uses_v2_errors),
) -> InvoiceStatusResponse: ) -> InvoiceStatusResponse:
invoice = await session.get(LightningInvoice, invoice_id) invoice = await session.get(LightningInvoice, invoice_id)
if not invoice: if not invoice:
@@ -404,6 +437,7 @@ async def get_invoice_status(
"Invoice not found", "Invoice not found",
"invalid_request_error", "invalid_request_error",
"invoice_not_found", "invoice_not_found",
structured=structured_errors is True,
) )
definitively_unpaid = False definitively_unpaid = False
@@ -432,10 +466,12 @@ async def get_invoice_status(
) )
@v2_lightning_router.post("/recover", response_model=InvoiceStatusResponse)
@lightning_router.post("/recover", response_model=InvoiceStatusResponse) @lightning_router.post("/recover", response_model=InvoiceStatusResponse)
async def recover_invoice( async def recover_invoice(
request: InvoiceRecoverRequest, request: InvoiceRecoverRequest,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
structured_errors: bool = Depends(_uses_v2_errors),
) -> InvoiceStatusResponse: ) -> InvoiceStatusResponse:
result = await session.exec( result = await session.exec(
select(LightningInvoice).where(LightningInvoice.bolt11 == request.bolt11) select(LightningInvoice).where(LightningInvoice.bolt11 == request.bolt11)
@@ -448,6 +484,7 @@ async def recover_invoice(
"Invoice not found", "Invoice not found",
"invalid_request_error", "invalid_request_error",
"invoice_not_found", "invoice_not_found",
structured=structured_errors is True,
) )
# 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
@@ -614,9 +651,7 @@ async def check_invoice_payment(
"invoice_id": settlement.id, "invoice_id": settlement.id,
"amount_sats": settlement.amount_sats, "amount_sats": settlement.amount_sats,
"purpose": settlement.purpose, "purpose": settlement.purpose,
"api_key_hash": api_key_hash[:8] + "..." "api_key_hash": api_key_hash[:8] + "..." if api_key_hash else None,
if api_key_hash
else None,
}, },
) )
return False return False
@@ -638,9 +673,7 @@ async def check_invoice_payment(
) )
await state_session.commit() await state_session.commit()
if pending.rowcount == 1: if pending.rowcount == 1:
_publish_invoice_value( _publish_invoice_value(invoice, "status", "settlement_pending")
invoice, "status", "settlement_pending"
)
except Exception as state_error: except Exception as state_error:
logger.critical( logger.critical(
"Paid invoice reconciliation state could not be persisted", "Paid invoice reconciliation state could not be persisted",
+143 -21
View File
@@ -1,9 +1,4 @@
"""RIP-08 lightning invoice endpoint tests. """Lightning invoice endpoint compatibility and v2 contract 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).
"""
from __future__ import annotations from __future__ import annotations
@@ -20,6 +15,9 @@ 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"
V2_PATH = "/v2/lightning/invoice"
COMPATIBILITY_PATHS = [RIP08_PATH, LEGACY_PATH]
ALL_PATHS = [*COMPATIBILITY_PATHS, V2_PATH]
@pytest_asyncio.fixture @pytest_asyncio.fixture
@@ -64,13 +62,13 @@ async def seeded_topup_key(integration_session: AsyncSession) -> str:
@pytest.mark.integration @pytest.mark.integration
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.parametrize("path", [RIP08_PATH, LEGACY_PATH]) @pytest.mark.parametrize("path", ALL_PATHS)
async def test_create_invoice_purpose_create( async def test_create_invoice_purpose_create(
integration_client: AsyncClient, integration_client: AsyncClient,
patch_invoice_generation: Any, patch_invoice_generation: Any,
path: str, path: str,
) -> None: ) -> 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( resp = await integration_client.post(
path, path,
json={"amount_sats": 1000, "purpose": "create"}, json={"amount_sats": 1000, "purpose": "create"},
@@ -85,7 +83,7 @@ async def test_create_invoice_purpose_create(
@pytest.mark.integration @pytest.mark.integration
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.parametrize("path", [RIP08_PATH, LEGACY_PATH]) @pytest.mark.parametrize("path", ALL_PATHS)
async def test_topup_with_authorization_header( async def test_topup_with_authorization_header(
integration_client: AsyncClient, integration_client: AsyncClient,
patch_invoice_generation: Any, patch_invoice_generation: Any,
@@ -108,14 +106,14 @@ async def test_topup_with_authorization_header(
@pytest.mark.integration @pytest.mark.integration
@pytest.mark.asyncio @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( async def test_topup_with_legacy_api_key_in_body(
integration_client: AsyncClient, integration_client: AsyncClient,
patch_invoice_generation: Any, patch_invoice_generation: Any,
seeded_topup_key: str, seeded_topup_key: str,
path: str, path: str,
) -> None: ) -> 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( resp = await integration_client.post(
path, path,
json={ json={
@@ -130,18 +128,33 @@ async def test_topup_with_legacy_api_key_in_body(
@pytest.mark.integration @pytest.mark.integration
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.parametrize("path", [RIP08_PATH, LEGACY_PATH]) @pytest.mark.parametrize("path", COMPATIBILITY_PATHS)
async def test_topup_missing_auth_returns_401( async def test_compatibility_topup_missing_auth_keeps_string_error(
integration_client: AsyncClient, integration_client: AsyncClient,
patch_invoice_generation: Any, patch_invoice_generation: Any,
path: str, path: str,
) -> None: ) -> None:
"""Topup without any credential is rejected on both paths."""
resp = await integration_client.post( resp = await integration_client.post(
path, path,
json={"amount_sats": 100, "purpose": "topup"}, json={"amount_sats": 100, "purpose": "topup"},
) )
assert resp.status_code == 401 assert resp.status_code == 401
assert resp.json()["detail"] == (
"Authorization bearer api key is required for topup"
)
@pytest.mark.integration
@pytest.mark.asyncio
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"] error = resp.json()["detail"]["error"]
assert error["type"] == "invalid_request_error" assert error["type"] == "invalid_request_error"
assert error["code"] == "topup_authorization_required" assert error["code"] == "topup_authorization_required"
@@ -149,8 +162,47 @@ async def test_topup_missing_auth_returns_401(
@pytest.mark.integration @pytest.mark.integration
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.parametrize("path", [RIP08_PATH, LEGACY_PATH]) @pytest.mark.parametrize(
async def test_topup_unknown_api_key_returns_404( "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, integration_client: AsyncClient,
patch_invoice_generation: Any, patch_invoice_generation: Any,
path: str, path: str,
@@ -161,6 +213,21 @@ 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
assert resp.json()["detail"] == "API key not found"
@pytest.mark.integration
@pytest.mark.asyncio
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"] error = resp.json()["detail"]["error"]
assert error["type"] == "invalid_request_error" assert error["type"] == "invalid_request_error"
assert error["code"] == "topup_api_key_not_found" assert error["code"] == "topup_api_key_not_found"
@@ -168,13 +235,22 @@ async def test_topup_unknown_api_key_returns_404(
@pytest.mark.integration @pytest.mark.integration
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.parametrize("path", [RIP08_PATH, LEGACY_PATH]) @pytest.mark.parametrize("path", COMPATIBILITY_PATHS)
async def test_invoice_status_404_for_unknown_id( async def test_compatibility_status_404_keeps_string_error(
integration_client: AsyncClient, integration_client: AsyncClient,
path: str, path: str,
) -> None: ) -> None:
base = path.rsplit("/invoice", 1)[0] + "/invoice" resp = await integration_client.get(f"{path}/does-not-exist/status")
resp = await integration_client.get(f"{base}/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 assert resp.status_code == 404
error = resp.json()["detail"]["error"] error = resp.json()["detail"]["error"]
assert error["type"] == "invalid_request_error" assert error["type"] == "invalid_request_error"
@@ -235,7 +311,53 @@ async def test_create_invoice_maps_mint_failures(
"routstr.lightning.generate_lightning_invoice", "routstr.lightning.generate_lightning_invoice",
side_effect=error, side_effect=error,
): ):
resp = await integration_client.post(RIP08_PATH, json={"amount_sats": 100}) resp = await integration_client.post(V2_PATH, json={"amount_sats": 100})
assert resp.status_code == status assert resp.status_code == status
assert resp.json()["detail"]["error"]["code"] == code assert resp.json()["detail"]["error"]["code"] == code
@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"
+13
View File
@@ -6,6 +6,7 @@ import pytest
from routstr.core.settings import settings from routstr.core.settings import settings
from routstr.lightning import _request_mint_with_fallback from routstr.lightning import _request_mint_with_fallback
from routstr.mint import MintCooldownError, is_mint_rate_limited
TRUSTED = "https://good-mint.example.com" TRUSTED = "https://good-mint.example.com"
UNTRUSTED = "https://removed-mint.example.com" UNTRUSTED = "https://removed-mint.example.com"
@@ -48,3 +49,15 @@ async def test_trusted_allowed_mints_are_used_verbatim() -> None:
await _request_mint_with_fallback(10, allowed_mints=[TRUSTED]) await _request_mint_with_fallback(10, allowed_mints=[TRUSTED])
assert attempted == [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)