From 78fc28558c4231cd206ffcb97c6d1bb8fe94d66c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Mon, 13 May 2024 17:58:48 +0200 Subject: [PATCH] refactor: catch payment and invoice error at faspi exceptionhandler level (#2484) refactor exceptionhandlers into `exception.py` also now always throw payment error when pay_invoice and invoice errors when create_invoice. return a status flag with the detailed error message. with a 520 response --- lnbits/app.py | 88 +-------------------------- lnbits/core/views/payment_api.py | 90 +++++++++++---------------- lnbits/exceptions.py | 101 +++++++++++++++++++++++++++++++ 3 files changed, 139 insertions(+), 140 deletions(-) create mode 100644 lnbits/exceptions.py diff --git a/lnbits/app.py b/lnbits/app.py index 642eca413..b8fb73ca5 100644 --- a/lnbits/app.py +++ b/lnbits/app.py @@ -4,22 +4,17 @@ import importlib import os import shutil import sys -import traceback from contextlib import asynccontextmanager -from http import HTTPStatus from pathlib import Path from typing import Callable, List, Optional -from fastapi import FastAPI, HTTPException, Request -from fastapi.exceptions import RequestValidationError +from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import RedirectResponse from fastapi.staticfiles import StaticFiles from loguru import logger from slowapi import Limiter from slowapi.util import get_remote_address from starlette.middleware.sessions import SessionMiddleware -from starlette.responses import JSONResponse from lnbits.core.crud import get_dbversions, get_installed_extensions from lnbits.core.helpers import migrate_extension_database @@ -27,6 +22,7 @@ from lnbits.core.tasks import ( # watchdog_task killswitch_task, wait_for_paid_invoices, ) +from lnbits.exceptions import register_exception_handlers from lnbits.settings import settings from lnbits.tasks import ( cancel_all_tasks, @@ -53,7 +49,6 @@ from .extension_manager import ( get_valid_extensions, version_parse, ) -from .helpers import template_renderer from .middleware import ( CustomGZipMiddleware, ExtensionsRedirectMiddleware, @@ -429,82 +424,3 @@ def register_async_tasks(): if settings.lnbits_admin_ui: server_log_task = initialize_server_websocket_logger() create_permanent_task(server_log_task) - - -def register_exception_handlers(app: FastAPI): - @app.exception_handler(Exception) - async def exception_handler(request: Request, exc: Exception): - etype, _, tb = sys.exc_info() - traceback.print_exception(etype, exc, tb) - logger.error(f"Exception: {exc!s}") - # Only the browser sends "text/html" request - # not fail proof, but everything else get's a JSON response - if ( - request.headers - and "accept" in request.headers - and "text/html" in request.headers["accept"] - ): - return template_renderer().TemplateResponse( - request, "error.html", {"err": f"Error: {exc!s}"} - ) - - return JSONResponse( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - content={"detail": str(exc)}, - ) - - @app.exception_handler(RequestValidationError) - async def validation_exception_handler( - request: Request, exc: RequestValidationError - ): - logger.error(f"RequestValidationError: {exc!s}") - # Only the browser sends "text/html" request - # not fail proof, but everything else get's a JSON response - - if ( - request.headers - and "accept" in request.headers - and "text/html" in request.headers["accept"] - ): - return template_renderer().TemplateResponse( - request, - "error.html", - {"err": f"Error: {exc!s}"}, - ) - - return JSONResponse( - status_code=HTTPStatus.BAD_REQUEST, - content={"detail": str(exc)}, - ) - - @app.exception_handler(HTTPException) - async def http_exception_handler(request: Request, exc: HTTPException): - logger.error(f"HTTPException {exc.status_code}: {exc.detail}") - # Only the browser sends "text/html" request - # not fail proof, but everything else get's a JSON response - - if ( - request.headers - and "accept" in request.headers - and "text/html" in request.headers["accept"] - ): - if exc.headers and "token-expired" in exc.headers: - response = RedirectResponse("/") - response.delete_cookie("cookie_access_token") - response.delete_cookie("is_lnbits_user_authorized") - response.set_cookie("is_access_token_expired", "true") - return response - - return template_renderer().TemplateResponse( - request, - "error.html", - { - "request": request, - "err": f"HTTP Error {exc.status_code}: {exc.detail}", - }, - ) - - return JSONResponse( - status_code=exc.status_code, - content={"detail": exc.detail}, - ) diff --git a/lnbits/core/views/payment_api.py b/lnbits/core/views/payment_api.py index ec5e027b8..c16f86858 100644 --- a/lnbits/core/views/payment_api.py +++ b/lnbits/core/views/payment_api.py @@ -55,8 +55,6 @@ from ..crud import ( update_pending_payments, ) from ..services import ( - InvoiceError, - PaymentError, check_transaction_status, create_invoice, fee_reserve_total, @@ -150,33 +148,25 @@ async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet): memo = "" async with db.connect() as conn: - try: - payment_hash, payment_request = await create_invoice( - wallet_id=wallet.id, - amount=data.amount, - memo=memo, - currency=data.unit, - description_hash=description_hash, - unhashed_description=unhashed_description, - expiry=data.expiry, - extra=data.extra, - webhook=data.webhook, - internal=data.internal, - conn=conn, - ) - # NOTE: we get the checking_id with a seperate query because create_invoice - # does not return it and it would be a big hustle to change its return type - # (used across extensions) - payment_db = await get_standalone_payment(payment_hash, conn=conn) - assert payment_db is not None, "payment not found" - checking_id = payment_db.checking_id - except InvoiceError as exc: - return JSONResponse( - status_code=520, - content={"detail": exc.message, "status": exc.status}, - ) - except Exception as exc: - raise exc + payment_hash, payment_request = await create_invoice( + wallet_id=wallet.id, + amount=data.amount, + memo=memo, + currency=data.unit, + description_hash=description_hash, + unhashed_description=unhashed_description, + expiry=data.expiry, + extra=data.extra, + webhook=data.webhook, + internal=data.internal, + conn=conn, + ) + # NOTE: we get the checking_id with a seperate query because create_invoice + # does not return it and it would be a big hustle to change its return type + # (used across extensions) + payment_db = await get_standalone_payment(payment_hash, conn=conn) + assert payment_db is not None, "payment not found" + checking_id = payment_db.checking_id invoice = bolt11.decode(payment_request) @@ -213,28 +203,6 @@ async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet): } -async def api_payments_pay_invoice( - bolt11: str, wallet: Wallet, extra: Optional[dict] = None -): - try: - payment_hash = await pay_invoice( - wallet_id=wallet.id, payment_request=bolt11, extra=extra - ) - except PaymentError as exc: - return JSONResponse( - status_code=520, - content={"detail": exc.message, "status": exc.status}, - ) - except Exception as exc: - raise exc - - return { - "payment_hash": payment_hash, - # maintain backwards compatibility with API clients: - "checking_id": payment_hash, - } - - @payment_router.post( "", summary="Create or pay an invoice", @@ -247,6 +215,11 @@ async def api_payments_pay_invoice( field to supply the BOLT11 invoice to be paid. """, status_code=HTTPStatus.CREATED, + responses={ + 400: {"description": "Invalid BOLT11 string or missing fields."}, + 401: {"description": "Invoice (or Admin) key required."}, + 520: {"description": "Payment or Invoice error."}, + }, ) async def api_payments_create( wallet: WalletTypeInfo = Depends(require_invoice_key), @@ -258,9 +231,18 @@ async def api_payments_create( status_code=HTTPStatus.BAD_REQUEST, detail="BOLT11 string is invalid or not given", ) - return await api_payments_pay_invoice( - invoice_data.bolt11, wallet.wallet, invoice_data.extra - ) # admin key + + payment_hash = await pay_invoice( + wallet_id=wallet.wallet.id, + payment_request=invoice_data.bolt11, + extra=invoice_data.extra, + ) + return { + "payment_hash": payment_hash, + # maintain backwards compatibility with API clients: + "checking_id": payment_hash, + } + elif not invoice_data.out: # invoice key return await api_payments_create_invoice(invoice_data, wallet.wallet) diff --git a/lnbits/exceptions.py b/lnbits/exceptions.py new file mode 100644 index 000000000..b6b71c396 --- /dev/null +++ b/lnbits/exceptions.py @@ -0,0 +1,101 @@ +import sys +import traceback +from http import HTTPStatus +from typing import Optional + +from fastapi import FastAPI, HTTPException, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse, RedirectResponse, Response +from loguru import logger + +from lnbits.core.services import InvoiceError, PaymentError + +from .helpers import template_renderer + + +def register_exception_handlers(app: FastAPI): + register_exception_handler(app) + register_request_validation_exception_handler(app) + register_http_exception_handler(app) + register_payment_error_handler(app) + register_invoice_error_handler(app) + + +def render_html_error(request: Request, exc: Exception) -> Optional[Response]: + # Only the browser sends "text/html" request + # not fail proof, but everything else get's a JSON response + if ( + request.headers + and "accept" in request.headers + and "text/html" in request.headers["accept"] + ): + if ( + isinstance(exc, HTTPException) + and exc.headers + and "token-expired" in exc.headers + ): + response = RedirectResponse("/") + response.delete_cookie("cookie_access_token") + response.delete_cookie("is_lnbits_user_authorized") + response.set_cookie("is_access_token_expired", "true") + return response + + return template_renderer().TemplateResponse( + request, "error.html", {"err": f"Error: {exc!s}"} + ) + + return None + + +def register_exception_handler(app: FastAPI): + @app.exception_handler(Exception) + async def exception_handler(request: Request, exc: Exception): + etype, _, tb = sys.exc_info() + traceback.print_exception(etype, exc, tb) + logger.error(f"Exception: {exc!s}") + return render_html_error(request, exc) or JSONResponse( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + content={"detail": str(exc)}, + ) + + +def register_request_validation_exception_handler(app: FastAPI): + @app.exception_handler(RequestValidationError) + async def validation_exception_handler( + request: Request, exc: RequestValidationError + ): + logger.error(f"RequestValidationError: {exc!s}") + return render_html_error(request, exc) or JSONResponse( + status_code=HTTPStatus.BAD_REQUEST, + content={"detail": str(exc)}, + ) + + +def register_http_exception_handler(app: FastAPI): + @app.exception_handler(HTTPException) + async def http_exception_handler(request: Request, exc: HTTPException): + logger.error(f"HTTPException {exc.status_code}: {exc.detail}") + return render_html_error(request, exc) or JSONResponse( + status_code=exc.status_code, + content={"detail": exc.detail}, + ) + + +def register_payment_error_handler(app: FastAPI): + @app.exception_handler(PaymentError) + async def payment_error_handler(request: Request, exc: PaymentError): + logger.error(f"PaymentError: {exc.message}, {exc.status}") + return JSONResponse( + status_code=520, + content={"detail": exc.message, "status": exc.status}, + ) + + +def register_invoice_error_handler(app: FastAPI): + @app.exception_handler(InvoiceError) + async def invoice_error_handler(request: Request, exc: InvoiceError): + logger.error(f"InvoiceError: {exc.message}, Status: {exc.status}") + return JSONResponse( + status_code=520, + content={"detail": exc.message, "status": exc.status}, + )