Compare commits

..
Author SHA1 Message Date
9qeklajc dc0e56e450 add new status code for refund not ready 2026-07-07 15:57:33 +02:00
2 changed files with 47 additions and 56 deletions
+37 -10
View File
@@ -259,11 +259,26 @@ async def refund_wallet_endpoint(
)
)
in_tx = in_tx_result.first()
cashu_fingerprint = hashlib.sha256(x_cashu.encode()).hexdigest()[:16]
if in_tx is None:
logger.info(
"refund_wallet_endpoint: x-cashu inbound transaction not found",
extra={"cashu_fingerprint": cashu_fingerprint},
)
raise HTTPException(status_code=404, detail="Refund not found")
# Use the request_id to find the associated "out" (refund) transaction
if in_tx.request_id is None:
logger.info(
"refund_wallet_endpoint: x-cashu inbound transaction has no request_id",
extra={
"cashu_fingerprint": cashu_fingerprint,
"cashu_transaction_id": in_tx.id,
"amount": in_tx.amount,
"unit": in_tx.unit,
"mint_url": in_tx.mint_url,
},
)
raise HTTPException(status_code=404, detail="Refund not found")
out_tx_result = await session.exec(
@@ -274,21 +289,33 @@ async def refund_wallet_endpoint(
)
out_tx = out_tx_result.first()
if out_tx is None:
# The "in" row exists with a request_id, but the "out" (refund)
# row hasn't been written yet — the upstream request is still in
# flight and the refund will be minted once it completes. Tell the
# client to retry instead of 404ing permanently (race condition
# where /v1/wallet/refund is polled before the refund exists).
logger.debug(
"refund_wallet_endpoint: refund pending (in row exists, out row not yet created)",
extra={"request_id": in_tx.request_id},
logger.info(
"refund_wallet_endpoint: x-cashu refund transaction not ready",
extra={
"cashu_fingerprint": cashu_fingerprint,
"cashu_transaction_id": in_tx.id,
"refund_request_id": in_tx.request_id,
"amount": in_tx.amount,
"unit": in_tx.unit,
"mint_url": in_tx.mint_url,
"age_seconds": max(0, int(time.time()) - in_tx.created_at),
},
)
raise HTTPException(
status_code=425,
detail="Refund is pending; retry shortly.",
headers={"Retry-After": "2"},
detail="Refund not ready. Retry later.",
headers={"Retry-After": "5"},
)
if out_tx.swept:
logger.info(
"refund_wallet_endpoint: x-cashu refund transaction already swept",
extra={
"cashu_fingerprint": cashu_fingerprint,
"cashu_transaction_id": in_tx.id,
"refund_transaction_id": out_tx.id,
"refund_request_id": in_tx.request_id,
},
)
raise HTTPException(status_code=410, detail="Refund has been swept")
out_tx.collected = True
+10 -46
View File
@@ -2,6 +2,7 @@ import json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import HTTPException
from fastapi.responses import JSONResponse
from routstr.balance import refund_wallet_endpoint
@@ -88,8 +89,6 @@ async def test_refund_x_cashu_sat_unit() -> None:
@pytest.mark.asyncio
async def test_refund_x_cashu_not_found_raises_404() -> None:
from fastapi import HTTPException
session = MagicMock()
session.exec = AsyncMock(return_value=_exec_result(None))
@@ -104,67 +103,32 @@ async def test_refund_x_cashu_not_found_raises_404() -> None:
@pytest.mark.asyncio
async def test_refund_x_cashu_pending_raises_425() -> None:
"""in row exists with a request_id but out row not yet created → 425.
This is the race condition where /v1/wallet/refund is polled while the
upstream request is still in flight. The endpoint must signal "retry"
rather than a permanent 404.
"""
from fastapi import HTTPException
x_cashu_token = "cashuApending_token"
async def test_refund_x_cashu_pending_out_tx_raises_425() -> None:
in_tx = _make_cashu_tx(
token=x_cashu_token, amount=0, unit="msat", type="in", request_id="req-pending"
token="cashuApending_token",
amount=0,
unit="msat",
type="in",
request_id="req-pending",
)
session = MagicMock()
session.exec = AsyncMock(side_effect=[_exec_result(in_tx), _exec_result(None)])
session.add = MagicMock()
session.commit = AsyncMock()
with pytest.raises(HTTPException) as exc_info:
await refund_wallet_endpoint(
authorization="Bearer sk-somekey",
x_cashu=x_cashu_token,
x_cashu="cashuApending_token",
session=session,
)
assert exc_info.value.status_code == 425
assert exc_info.value.headers == {"Retry-After": "2"}
@pytest.mark.asyncio
async def test_refund_x_cashu_in_tx_without_request_id_raises_404() -> None:
"""in row exists but has no request_id (cannot link to a refund) → 404.
This is a genuine "no refund will ever exist" case, distinct from the
pending 425 path.
"""
from fastapi import HTTPException
x_cashu_token = "cashuAnoreqid_token"
in_tx = _make_cashu_tx(
token=x_cashu_token, amount=0, unit="msat", type="in", request_id=None
)
session = MagicMock()
session.exec = AsyncMock(side_effect=[_exec_result(in_tx)])
with pytest.raises(HTTPException) as exc_info:
await refund_wallet_endpoint(
authorization="Bearer sk-somekey",
x_cashu=x_cashu_token,
session=session,
)
assert exc_info.value.status_code == 404
assert exc_info.value.detail == "Refund not ready. Retry later."
assert exc_info.value.headers == {"Retry-After": "5"}
@pytest.mark.asyncio
async def test_refund_x_cashu_swept_raises_410() -> None:
from fastapi import HTTPException
in_tx = _make_cashu_tx(token="cashuAswept_token", amount=0, unit="msat", type="in", request_id="req-swept")
out_tx = _make_cashu_tx(token="cashuAswept", amount=100, unit="msat", type="out", request_id="req-swept", swept=True)