diff --git a/docs/api/errors.md b/docs/api/errors.md index 66595d92..e74a46f4 100644 --- a/docs/api/errors.md +++ b/docs/api/errors.md @@ -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. diff --git a/routstr/lightning.py b/routstr/lightning.py index 98ade521..e7b8046a 100644 --- a/routstr/lightning.py +++ b/routstr/lightning.py @@ -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( diff --git a/tests/integration/test_lightning_invoice_rip08.py b/tests/integration/test_lightning_invoice_rip08.py index 76ac0115..d3915e0e 100644 --- a/tests/integration/test_lightning_invoice_rip08.py +++ b/tests/integration/test_lightning_invoice_rip08.py @@ -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(