mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 12:28:22 +00:00
fix: version lightning error contract under v2
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 any mint
|
||||
cooldown. 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
|
||||
|
||||
@@ -53,10 +55,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
|
||||
|
||||
|
||||
@@ -17,7 +17,11 @@ from ..auth import (
|
||||
periodic_stale_reservation_sweep,
|
||||
)
|
||||
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 (
|
||||
announce_provider,
|
||||
providers_cache_refresher,
|
||||
@@ -422,6 +426,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)
|
||||
|
||||
+60
-27
@@ -8,7 +8,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 +24,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 +39,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.
|
||||
@@ -201,6 +204,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:
|
||||
@@ -225,8 +229,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={
|
||||
@@ -236,6 +243,11 @@ async def _request_mint_with_fallback(
|
||||
},
|
||||
)
|
||||
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}")
|
||||
|
||||
|
||||
@@ -255,24 +267,41 @@ 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
|
||||
status_code: int,
|
||||
message: str,
|
||||
error_type: str,
|
||||
code: str,
|
||||
*,
|
||||
structured: bool,
|
||||
legacy_message: str | None = None,
|
||||
) -> 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}},
|
||||
)
|
||||
"""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)
|
||||
|
||||
|
||||
def _invoice_creation_error(error: Exception) -> HTTPException:
|
||||
"""Map an invoice-creation failure to its specific client-facing error."""
|
||||
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):
|
||||
return _invoice_error(
|
||||
503,
|
||||
"Cashu mint rate-limited; retry after cooldown",
|
||||
"mint_rate_limited",
|
||||
"lightning_mint_rate_limited",
|
||||
structured=True,
|
||||
)
|
||||
if is_mint_connection_error(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",
|
||||
"mint_unreachable",
|
||||
"lightning_mint_unreachable",
|
||||
)
|
||||
if isinstance(error, ValueError):
|
||||
return _invoice_error(
|
||||
400,
|
||||
"Invalid invoice amount",
|
||||
"invalid_request_error",
|
||||
"invalid_invoice_amount",
|
||||
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
|
||||
|
||||
@@ -312,6 +339,7 @@ async def create_invoice(
|
||||
"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 _invoice_error(
|
||||
@@ -319,6 +347,8 @@ async def create_invoice(
|
||||
"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:])
|
||||
@@ -328,6 +358,7 @@ async def create_invoice(
|
||||
"API key not found",
|
||||
"invalid_request_error",
|
||||
"topup_api_key_not_found",
|
||||
structured=structured_errors,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -338,9 +369,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
|
||||
)
|
||||
@@ -387,15 +416,19 @@ async def create_invoice(
|
||||
|
||||
except Exception as 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(
|
||||
"/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:
|
||||
@@ -404,6 +437,7 @@ async def get_invoice_status(
|
||||
"Invoice not found",
|
||||
"invalid_request_error",
|
||||
"invoice_not_found",
|
||||
structured=structured_errors is True,
|
||||
)
|
||||
|
||||
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)
|
||||
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)
|
||||
@@ -448,6 +484,7 @@ async def recover_invoice(
|
||||
"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
|
||||
@@ -614,9 +651,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
|
||||
@@ -638,9 +673,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
|
||||
|
||||
@@ -20,6 +15,9 @@ 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
|
||||
@@ -64,13 +62,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"},
|
||||
@@ -85,7 +83,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,
|
||||
@@ -108,14 +106,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={
|
||||
@@ -130,18 +128,33 @@ 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
|
||||
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"
|
||||
@@ -149,8 +162,47 @@ async def test_topup_missing_auth_returns_401(
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("path", [RIP08_PATH, LEGACY_PATH])
|
||||
async def test_topup_unknown_api_key_returns_404(
|
||||
@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,
|
||||
@@ -161,6 +213,21 @@ 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
|
||||
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"
|
||||
@@ -168,13 +235,22 @@ async def test_topup_unknown_api_key_returns_404(
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("path", [RIP08_PATH, LEGACY_PATH])
|
||||
async def test_invoice_status_404_for_unknown_id(
|
||||
@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"
|
||||
@@ -235,7 +311,53 @@ async def test_create_invoice_maps_mint_failures(
|
||||
"routstr.lightning.generate_lightning_invoice",
|
||||
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.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"
|
||||
|
||||
@@ -6,6 +6,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"
|
||||
@@ -48,3 +49,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