This commit is contained in:
9qeklajc
2026-09-12 16:16:33 +02:00
parent b1d43bf722
commit f67deb761a
3 changed files with 37 additions and 7 deletions
+2 -2
View File
@@ -36,8 +36,8 @@ HTTP response, `error` is available at the top level and mirrored under
| Unexpected invoice creation failure | 500 | `api_error` | `invoice_creation_failed` | | Unexpected invoice creation failure | 500 | `api_error` | `invoice_creation_failed` |
Request validation failures, including non-positive or excessive amounts, use 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 FastAPI's standard 422 validation response. Only the 503 mint failures are retryable. Use backoff and honor the
cooldown. The compatibility endpoints `/lightning/*` and `Retry-After` header when present. The compatibility endpoints `/lightning/*` and
`/v1/balance/lightning/*` retain their original string `detail` errors and `/v1/balance/lightning/*` retain their original string `detail` errors and
legacy status behavior. legacy status behavior.
+18 -5
View File
@@ -1,5 +1,6 @@
import asyncio import asyncio
import hashlib import hashlib
import math
import re import re
import secrets import secrets
import time import time
@@ -244,10 +245,8 @@ async def _request_mint_with_fallback(
) )
continue continue
if all_rate_limited: if all_rate_limited:
retry_after = max( slowest = max(candidates, key=mint_cooldown_remaining)
(mint_cooldown_remaining(mint) for mint in candidates), default=0.0 raise MintCooldownError(slowest, mint_cooldown_remaining(slowest))
)
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}")
@@ -279,6 +278,7 @@ def _invoice_error(
*, *,
structured: bool, structured: bool,
legacy_message: str | None = None, legacy_message: str | None = None,
headers: dict[str, str] | None = None,
) -> HTTPException: ) -> HTTPException:
"""Build either the legacy string detail or the v2 typed envelope.""" """Build either the legacy string detail or the v2 typed envelope."""
detail: str | dict[str, dict[str, str]] detail: str | dict[str, dict[str, str]]
@@ -286,7 +286,18 @@ def _invoice_error(
detail = {"error": {"message": message, "type": error_type, "code": code}} detail = {"error": {"message": message, "type": error_type, "code": code}}
else: else:
detail = legacy_message or message detail = legacy_message or message
return HTTPException(status_code=status_code, detail=detail) 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: def _invoice_creation_error(error: Exception, *, structured: bool) -> HTTPException:
@@ -296,12 +307,14 @@ def _invoice_creation_error(error: Exception, *, structured: bool) -> HTTPExcept
status_code=500, detail="Failed to create Lightning invoice" status_code=500, detail="Failed to create Lightning invoice"
) )
if is_mint_rate_limited(error): if is_mint_rate_limited(error):
retry_after = _mint_retry_after(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, structured=True,
headers={"Retry-After": str(retry_after)} if retry_after else None,
) )
if is_mint_connection_error(error): if is_mint_connection_error(error):
return _invoice_error( return _invoice_error(
@@ -11,6 +11,7 @@ from httpx import AsyncClient
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from routstr.core.db import ApiKey from routstr.core.db import ApiKey
from routstr.mint import MintCooldownError
from routstr.wallet import MintConnectionError from routstr.wallet import MintConnectionError
RIP08_PATH = "/lightning/invoice" RIP08_PATH = "/lightning/invoice"
@@ -317,6 +318,22 @@ async def test_create_invoice_maps_mint_failures(
assert resp.json()["detail"]["error"]["code"] == code 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.integration
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_compatibility_create_failure_keeps_generic_error( async def test_compatibility_create_failure_keeps_generic_error(