Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cached Requests and Responses #624

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
Open
7 changes: 7 additions & 0 deletions cashu/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,12 @@ class CoreLightningRestFundingSource(MintSettings):
mint_corelightning_rest_cert: Optional[str] = Field(default=None)


class MintRedisCache(MintSettings):
mint_redis_cache_enabled: bool = Field(default=False)
mint_redis_cache_url: Optional[str] = Field(default=None)
mint_redis_cache_ttl: int = Field(default=3600)


class Settings(
EnvSettings,
LndRPCFundingSource,
Expand All @@ -240,6 +246,7 @@ class Settings(
FakeWalletSettings,
MintLimits,
MintBackends,
MintRedisCache,
MintDeprecationFlags,
MintSettings,
MintInformation,
Expand Down
43 changes: 22 additions & 21 deletions cashu/mint/app.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import asyncio
import sys
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from traceback import print_exception

from fastapi import FastAPI, status
Expand All @@ -10,7 +13,7 @@
from ..core.errors import CashuError
from ..core.logging import configure_logger
from ..core.settings import settings
from .router import router
from .router import redis, router
from .router_deprecated import router_deprecated
from .startup import shutdown_mint as shutdown_mint_init
from .startup import start_mint_init
Expand All @@ -23,27 +26,35 @@

from .middleware import add_middlewares, request_validation_exception_handler

# this errors with the tests but is the appropriate way to handle startup and shutdown
# until then, we use @app.on_event("startup")
# @asynccontextmanager
# async def lifespan(app: FastAPI):
# # startup routines here
# await start_mint_init()
# yield
# # shutdown routines here

@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
await start_mint_init()
try:
yield
except asyncio.CancelledError:
# Handle the cancellation gracefully
logger.info("Shutdown process interrupted by CancelledError")
finally:
try:
await redis.disconnect()
await shutdown_mint_init()
except asyncio.CancelledError:
logger.info("CancelledError during shutdown, shutting down forcefully")


def create_app(config_object="core.settings") -> FastAPI:
configure_logger()

app = FastAPI(
title="Nutshell Cashu Mint",
description="Ecash wallet and mint based on the Cashu protocol.",
title="Nutshell Mint",
description="Ecash mint based on the Cashu protocol.",
version=settings.version,
license_info={
"name": "MIT License",
"url": "https://raw.githubusercontent.com/cashubtc/cashu/main/LICENSE",
},
lifespan=lifespan,
)

return app
Expand Down Expand Up @@ -99,13 +110,3 @@ async def catch_exceptions(request: Request, call_next):
else:
app.include_router(router=router, tags=["Mint"])
app.include_router(router=router_deprecated, tags=["Deprecated"], deprecated=True)


@app.on_event("startup")
async def startup_mint():
await start_mint_init()


@app.on_event("shutdown")
async def shutdown_mint():
await shutdown_mint_init()
56 changes: 56 additions & 0 deletions cashu/mint/cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import functools
import json

from loguru import logger
from redis.asyncio import from_url
from redis.exceptions import ConnectionError

from ..core.errors import CashuError
from ..core.settings import settings


class RedisCache:
def __init__(self):
if settings.mint_redis_cache_enabled:
if settings.mint_redis_cache_url is None:
raise CashuError("Redis cache url not provided")
self.redis = from_url(settings.mint_redis_cache_url)
# PING
try:
self.redis.ping()
logger.info("PONG from redis ✅")
except ConnectionError as e:
logger.error("redis connection error 💀")
raise e

def cache(self, expire):
def passthrough(func):
@functools.wraps(func)
async def wrapper(*args, **kwargs):
logger.debug(f"cache wrapper on route {func.__name__}")
result = await func(*args, **kwargs)
return result
return wrapper
def decorator(func):
@functools.wraps(func)
async def wrapper(request, payload):
logger.debug(f"cache wrapper on route {func.__name__}")
key = request.url.path + payload.json()
logger.debug(f"KEY: {key}")
# Check if we have a value under this key
if await self.redis.exists(key):
logger.info("Returning a cached response...")
return json.loads(await self.redis.get(key))
result = await func(request, payload)
# Cache a successful result for `expire` seconds
await self.redis.setex(key, expire, result.json())
return result
return wrapper
return (
passthrough
if not settings.mint_redis_cache_enabled
else decorator
)

async def disconnect(self):
await self.redis.close()
11 changes: 8 additions & 3 deletions cashu/mint/router.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import asyncio
import time

from fastapi import APIRouter, Request, WebSocket
from fastapi import APIRouter, WebSocket
from loguru import logger
from starlette.requests import Request

from ..core.errors import KeysetNotFoundError
from ..core.models import (
Expand All @@ -28,10 +29,11 @@
)
from ..core.settings import settings
from ..mint.startup import ledger
from .cache import RedisCache
from .limit import limit_websocket, limiter

router: APIRouter = APIRouter()

router = APIRouter()
redis = RedisCache()

@router.get(
"/v1/info",
Expand Down Expand Up @@ -231,6 +233,7 @@ async def websocket_endpoint(websocket: WebSocket):
),
)
@limiter.limit(f"{settings.mint_transaction_rate_limit_per_minute}/minute")
@redis.cache(expire=settings.mint_redis_cache_ttl)
async def mint(
request: Request,
payload: PostMintRequest,
Expand Down Expand Up @@ -308,6 +311,7 @@ async def get_melt_quote(request: Request, quote: str) -> PostMeltQuoteResponse:
),
)
@limiter.limit(f"{settings.mint_transaction_rate_limit_per_minute}/minute")
@redis.cache(expire=settings.mint_redis_cache_ttl)
async def melt(request: Request, payload: PostMeltRequest) -> PostMeltQuoteResponse:
"""
Requests tokens to be destroyed and sent out via Lightning.
Expand All @@ -330,6 +334,7 @@ async def melt(request: Request, payload: PostMeltRequest) -> PostMeltQuoteRespo
),
)
@limiter.limit(f"{settings.mint_transaction_rate_limit_per_minute}/minute")
@redis.cache(expire=settings.mint_redis_cache_ttl)
async def swap(
request: Request,
payload: PostSwapRequest,
Expand Down
20 changes: 19 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ googleapis-common-protos = "^1.63.2"
mypy-protobuf = "^3.6.0"
types-protobuf = "^5.27.0.20240626"
grpcio-tools = "^1.65.1"
redis = "^5.1.1"

[tool.poetry.group.dev.dependencies]
pytest-asyncio = "^0.24.0"
Expand Down
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
settings.mint_clnrest_enable_mpp = True
settings.mint_input_fee_ppk = 0
settings.db_connection_pool = True
#settings.mint_redis_cache_activate = True

assert "test" in settings.cashu_dir
shutil.rmtree(settings.cashu_dir, ignore_errors=True)
Expand Down
109 changes: 109 additions & 0 deletions tests/test_mint_api_cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import httpx
import pytest
import pytest_asyncio

from cashu.core.settings import settings
from cashu.mint.ledger import Ledger
from cashu.wallet.wallet import Wallet
from tests.helpers import pay_if_regtest

BASE_URL = "http://localhost:3337"
invoice_32sat = "lnbc320n1pnsuamsdqqxqrrsssp5w3tlpw2zss396qh28l3a07u35zdx8nmknzryk89ackn23eywdu2spp5ckt298t835ejzh2xepyxlg57f54q27ffc2zjsjh3t5pmx4wghpcqne0vycw5dfalx5y45d2jtwqfwz437hduyccn9nxk2feay0ytxldjpf3fcjrcf5k2s56q3erj86ymlqdp703y89vt4lr4lun5z5duulcqwuwutn"

@pytest_asyncio.fixture(scope="function")
async def wallet(ledger: Ledger):
wallet1 = await Wallet.with_db(
url=BASE_URL,
db="test_data/wallet_mint_api",
name="wallet_mint_api",
)
await wallet1.load_mint()
yield wallet1


@pytest.mark.asyncio
@pytest.mark.skipif(
not settings.mint_redis_cache_enabled,
reason="settings.mint_redis_cache_enabled is False",
)
async def test_api_mint_cached_responses(wallet: Wallet):
# Testing mint
invoice = await wallet.request_mint(64)
await pay_if_regtest(invoice.request)

quote_id = invoice.quote
secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(10010, 10011)
outputs, rs = wallet._construct_outputs([32, 32], secrets, rs)
outputs_payload = [o.dict() for o in outputs]
response = httpx.post(
f"{BASE_URL}/v1/mint/bolt11",
json={"quote": quote_id, "outputs": outputs_payload},
timeout=None,
)
response1 = httpx.post(
f"{BASE_URL}/v1/mint/bolt11",
json={"quote": quote_id, "outputs": outputs_payload},
timeout=None,
)
assert response.status_code == 200, f"{response.status_code = }"
assert response1.status_code == 200, f"{response1.status_code = }"
assert response.text == response1.text

@pytest.mark.asyncio
@pytest.mark.skipif(
not settings.mint_redis_cache_enabled,
reason="settings.mint_redis_cache_enabled is False",
)
async def test_api_swap_cached_responses(wallet: Wallet):
quote = await wallet.request_mint(64)
await pay_if_regtest(quote.request)

minted = await wallet.mint(64, quote.quote)
secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(10010, 10011)
outputs, rs = wallet._construct_outputs([32, 32], secrets, rs)
inputs_payload = [i.dict() for i in minted]
outputs_payload = [o.dict() for o in outputs]
response = httpx.post(
f"{BASE_URL}/v1/swap",
json={"inputs": inputs_payload, "outputs": outputs_payload},
timeout=None,
)
response1 = httpx.post(
f"{BASE_URL}/v1/swap",
json={"inputs": inputs_payload, "outputs": outputs_payload},
timeout=None,
)
assert response.status_code == 200, f"{response.status_code = }"
assert response1.status_code == 200, f"{response1.status_code = }"
assert response.text == response1.text

@pytest.mark.asyncio
@pytest.mark.skipif(
not settings.mint_redis_cache_enabled,
reason="settings.mint_redis_cache_enabled is False",
)
async def test_api_melt_cached_responses(wallet: Wallet):
quote = await wallet.request_mint(64)
melt_quote = await wallet.melt_quote(invoice_32sat)

await pay_if_regtest(quote.request)
minted = await wallet.mint(64, quote.quote)

secrets, rs, derivation_paths = await wallet.generate_secrets_from_to(10010, 10010)
outputs, rs = wallet._construct_outputs([32], secrets, rs)

inputs_payload = [i.dict() for i in minted]
outputs_payload = [o.dict() for o in outputs]
response = httpx.post(
f"{BASE_URL}/v1/melt/bolt11",
json={"quote": melt_quote.quote, "inputs": inputs_payload, "outputs": outputs_payload},
timeout=None,
)
response1 = httpx.post(
f"{BASE_URL}/v1/melt/bolt11",
json={"quote": melt_quote.quote, "inputs": inputs_payload, "outputs": outputs_payload},
timeout=None,
)
assert response.status_code == 200, f"{response.status_code = }"
assert response1.status_code == 200, f"{response1.status_code = }"
assert response.text == response1.text
Loading