Compare commits

..
Author SHA1 Message Date
redshift a9d5a52b32 fix(wallet): return 425 when refund is pending, not 404
The X-Cashu refund endpoint raised 404 "Refund not found" when the
"in" transaction existed with a request_id but the "out" (refund)
transaction had not been written yet. This is a timing race: the
endpoint is polled while the upstream request is still in flight,
before send_refund() has minted and stored the refund token.

The 404 was indistinguishable from a genuinely-missing refund, so
clients had no signal that retrying would succeed — leading to
stranded refunds when clients gave up.

Replace the third 404 branch with 425 Too Early + Retry-After: 2.
The two earlier 404 branches (no "in" row, no request_id) remain 404
since those genuinely mean no refund will ever exist.

Also adds debug logging on all three not-found/pending branches so
the race is no longer invisible to operators (middleware does not log
request headers).

Adds unit tests for the new 425 pending path and the no-request_id
404 path.

Refs: refund-race-condition
2026-07-07 21:49:32 +08:00
2 changed files with 56 additions and 47 deletions
+10 -37
View File
@@ -259,26 +259,11 @@ 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(
@@ -289,33 +274,21 @@ async def refund_wallet_endpoint(
)
out_tx = out_tx_result.first()
if out_tx is None:
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),
},
# 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},
)
raise HTTPException(
status_code=425,
detail="Refund not ready. Retry later.",
headers={"Retry-After": "5"},
detail="Refund is pending; retry shortly.",
headers={"Retry-After": "2"},
)
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
+46 -10
View File
@@ -2,7 +2,6 @@ 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
@@ -89,6 +88,8 @@ 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))
@@ -103,32 +104,67 @@ async def test_refund_x_cashu_not_found_raises_404() -> None:
@pytest.mark.asyncio
async def test_refund_x_cashu_pending_out_tx_raises_425() -> None:
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"
in_tx = _make_cashu_tx(
token="cashuApending_token",
amount=0,
unit="msat",
type="in",
request_id="req-pending",
token=x_cashu_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="cashuApending_token",
x_cashu=x_cashu_token,
session=session,
)
assert exc_info.value.status_code == 425
assert exc_info.value.detail == "Refund not ready. Retry later."
assert exc_info.value.headers == {"Retry-After": "5"}
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
@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)