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` |
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
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.
+18 -5
View File
@@ -1,5 +1,6 @@
import asyncio
import hashlib
import math
import re
import secrets
import time
@@ -244,10 +245,8 @@ 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)
slowest = max(candidates, key=mint_cooldown_remaining)
raise MintCooldownError(slowest, mint_cooldown_remaining(slowest))
raise MintConnectionError(f"All mints failed for request_mint: {tried}")
@@ -279,6 +278,7 @@ def _invoice_error(
*,
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]]
@@ -286,7 +286,18 @@ def _invoice_error(
detail = {"error": {"message": message, "type": error_type, "code": code}}
else:
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:
@@ -296,12 +307,14 @@ def _invoice_creation_error(error: Exception, *, structured: bool) -> HTTPExcept
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(
@@ -11,6 +11,7 @@ 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"
@@ -317,6 +318,22 @@ async def test_create_invoice_maps_mint_failures(
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(