diff --git a/.env-devel b/.env-devel index a9e6357b1db..f1ccb07e520 100644 --- a/.env-devel +++ b/.env-devel @@ -1,3 +1,4 @@ +# local development # # - Keep it alfphabetical order and grouped by prefix [see vscode cmd: Sort Lines Ascending] # - To expose: diff --git a/packages/models-library/src/models_library/api_schemas_payments/errors.py b/packages/models-library/src/models_library/api_schemas_payments/errors.py new file mode 100644 index 00000000000..eaeba92aab1 --- /dev/null +++ b/packages/models-library/src/models_library/api_schemas_payments/errors.py @@ -0,0 +1,65 @@ +from pydantic.errors import PydanticErrorMixin + + +class _BaseRpcApiError(PydanticErrorMixin, ValueError): + @classmethod + def get_full_class_name(cls) -> str: + # Can be used as unique code identifier + return f"{cls.__module__}.{cls.__name__}" + + +# +# service-wide errors +# + + +class PaymentServiceUnavailableError(_BaseRpcApiError): + msg_template = "Payments are currently unavailable: {human_readable_detail}" + + +# +# payment transactions errors +# + + +class PaymentsError(_BaseRpcApiError): + msg_template = "Error in payment transaction '{payment_id}'" + + +class PaymentNotFoundError(PaymentsError): + msg_template = "Payment transaction '{payment_id}' was not found" + + +class PaymentAlreadyExistsError(PaymentsError): + msg_template = "Payment transaction '{payment_id}' was already initialized" + + +class PaymentAlreadyAckedError(PaymentsError): + msg_template = "Payment transaction '{payment_id}' cannot be changes since it was already closed." + + +# +# payment-methods errors +# + + +class PaymentsMethodsError(_BaseRpcApiError): + ... + + +class PaymentMethodNotFoundError(PaymentsMethodsError): + msg_template = "The specified payment method '{payment_method_id}' does not exist" + + +class PaymentMethodAlreadyAckedError(PaymentsMethodsError): + msg_template = ( + "Cannot create payment-method '{payment_method_id}' since it was already closed" + ) + + +class PaymentMethodUniqueViolationError(PaymentsMethodsError): + msg_template = "Payment method '{payment_method_id}' aready exists" + + +class InvalidPaymentMethodError(PaymentsMethodsError): + msg_template = "Invalid payment method '{payment_method_id}'" diff --git a/packages/service-library/src/servicelib/fastapi/app_state.py b/packages/service-library/src/servicelib/fastapi/app_state.py new file mode 100644 index 00000000000..b15cbcb261e --- /dev/null +++ b/packages/service-library/src/servicelib/fastapi/app_state.py @@ -0,0 +1,36 @@ +import logging + +from fastapi import FastAPI + +_logger = logging.getLogger(__name__) + + +class SingletonInAppStateMixin: + """ + Mixin to get, set and delete an instance of 'self' from/to app.state + """ + + app_state_name: str # Name used in app.state.$(app_state_name) + frozen: bool = True # Will raise if set multiple times + + @classmethod + def get_from_app_state(cls, app: FastAPI): + return getattr(app.state, cls.app_state_name) + + def set_to_app_state(self, app: FastAPI): + if (exists := getattr(app.state, self.app_state_name, None)) and self.frozen: + msg = f"An instance of {type(self)} already in app.state.{self.app_state_name}={exists}" + raise ValueError(msg) + + setattr(app.state, self.app_state_name, self) + return self.get_from_app_state(app) + + @classmethod + def pop_from_app_state(cls, app: FastAPI): + """ + Raises: + AttributeError: if instance is not in app.state + """ + old = getattr(app.state, cls.app_state_name) + delattr(app.state, cls.app_state_name) + return old diff --git a/packages/service-library/src/servicelib/fastapi/http_client.py b/packages/service-library/src/servicelib/fastapi/http_client.py index c27f8ab6f54..7ff9ed2633c 100644 --- a/packages/service-library/src/servicelib/fastapi/http_client.py +++ b/packages/service-library/src/servicelib/fastapi/http_client.py @@ -58,73 +58,3 @@ async def check_liveness(self) -> LivenessResult: return IsResponsive(elapsed=response.elapsed) except httpx.RequestError as err: return IsNonResponsive(reason=f"{err}") - - -class AppStateMixin: - """ - Mixin to get, set and delete an instance of 'self' from/to app.state - """ - - app_state_name: str # Name used in app.state.$(app_state_name) - frozen: bool = True # Will raise if set multiple times - - @classmethod - def get_from_app_state(cls, app: FastAPI): - return getattr(app.state, cls.app_state_name) - - def set_to_app_state(self, app: FastAPI): - if (exists := getattr(app.state, self.app_state_name, None)) and self.frozen: - msg = f"An instance of {type(self)} already in app.state.{self.app_state_name}={exists}" - raise ValueError(msg) - - setattr(app.state, self.app_state_name, self) - return self.get_from_app_state(app) - - @classmethod - def pop_from_app_state(cls, app: FastAPI): - """ - Raises: - AttributeError: if instance is not in app.state - """ - old = getattr(app.state, cls.app_state_name) - delattr(app.state, cls.app_state_name) - return old - - -def to_curl_command(request: httpx.Request, *, use_short_options: bool = True) -> str: - """Composes a curl command from a given request - - Can be used to reproduce a request in a separate terminal (e.g. debugging) - """ - # Adapted from https://github.com/marcuxyz/curlify2/blob/master/curlify2/curlify.py - method = request.method - url = request.url - - # https://curl.se/docs/manpage.html#-X - # -X, --request {method} - _x = "-X" if use_short_options else "--request" - request_option = f"{_x} {method}" - - # https://curl.se/docs/manpage.html#-d - # -d, --data HTTP POST data - data_option = "" - if body := request.read().decode(): - _d = "-d" if use_short_options else "--data" - data_option = f"{_d} '{body}'" - - # https://curl.se/docs/manpage.html#-H - # H, --header
Pass custom header(s) to server - - headers_option = "" - headers = [] - for key, value in request.headers.items(): - if "secret" in key.lower() or "pass" in key.lower(): - headers.append(f'"{key}: *****"') - else: - headers.append(f'"{key}: {value}"') - - if headers: - _h = "-H" if use_short_options else "--header" - headers_option = f"{_h} {f' {_h} '.join(headers)}" - - return f"curl {request_option} {headers_option} {data_option} {url}" diff --git a/packages/service-library/src/servicelib/fastapi/httpx_utils.py b/packages/service-library/src/servicelib/fastapi/httpx_utils.py new file mode 100644 index 00000000000..b8fcc8fd291 --- /dev/null +++ b/packages/service-library/src/servicelib/fastapi/httpx_utils.py @@ -0,0 +1,84 @@ +import httpx + + +def _is_secret(k: str) -> bool: + return "secret" in k.lower() or "pass" in k.lower() + + +def _get_headers_safely(request: httpx.Request) -> dict[str, str]: + return {k: "*" * 5 if _is_secret(k) else v for k, v in request.headers.items()} + + +def to_httpx_command( + request: httpx.Request, *, use_short_options: bool = True, multiline: bool = False +) -> str: + """Command with httpx CLI + + $ httpx --help + + NOTE: Particularly handy as an alternative to curl (e.g. when docker exec in osparc containers) + SEE https://www.python-httpx.org/ + """ + cmd = [ + "httpx", + ] + + # -m, --method METHOD + cmd.append(f'{"-m" if use_short_options else "--method"} {request.method}') + + # -c, --content TEXT Byte content to include in the request body. + if content := request.read().decode(): + cmd.append(f'{"-c" if use_short_options else "--content"} \'{content}\'') + + # -h, --headers ... Include additional HTTP headers in the request. + if headers := _get_headers_safely(request): + cmd.extend( + [ + f'{"-h" if use_short_options else "--headers"} "{name}" "{value}"' + for name, value in headers.items() + ] + ) + + cmd.append(f"{request.url}") + separator = " \\\n" if multiline else " " + return separator.join(cmd) + + +def to_curl_command( + request: httpx.Request, *, use_short_options: bool = True, multiline: bool = False +) -> str: + """Composes a curl command from a given request + + $ curl --help + + NOTE: Handy reproduce a request in a separate terminal (e.g. debugging) + """ + # Adapted from https://github.com/marcuxyz/curlify2/blob/master/curlify2/curlify.py + cmd = [ + "curl", + ] + + # https://curl.se/docs/manpage.html#-X + # -X, --request {method} + cmd.append(f'{"-X" if use_short_options else "--request"} {request.method}') + + # https://curl.se/docs/manpage.html#-H + # H, --header
Pass custom header(s) to server + if headers := _get_headers_safely(request): + cmd.extend( + [ + f'{"-H" if use_short_options else "--header"} "{k}: {v}"' + for k, v in headers.items() + ] + ) + + # https://curl.se/docs/manpage.html#-d + # -d, --data HTTP POST data + if body := request.read().decode(): + _d = "-d" if use_short_options else "--data" + cmd.append(f"{_d} '{body}'") + + cmd.append(f"{request.url}") + + separator = " \\\n" if multiline else " " + return separator.join(cmd) diff --git a/packages/service-library/src/servicelib/rabbitmq/_client_rpc.py b/packages/service-library/src/servicelib/rabbitmq/_client_rpc.py index eea5af6f4bd..e04cf5e604e 100644 --- a/packages/service-library/src/servicelib/rabbitmq/_client_rpc.py +++ b/packages/service-library/src/servicelib/rabbitmq/_client_rpc.py @@ -12,6 +12,7 @@ from ..logging_utils import log_context from ._client_base import RabbitMQClientBase +from ._constants import RPC_REQUEST_DEFAULT_TIMEOUT_S from ._errors import RemoteMethodNotRegisteredError, RPCNotInitializedError from ._models import RPCNamespacedMethodName from ._rpc_router import RPCRouter @@ -65,7 +66,7 @@ async def request( namespace: RPCNamespace, method_name: RPCMethodName, *, - timeout_s: PositiveInt | None = 5, + timeout_s: PositiveInt | None = RPC_REQUEST_DEFAULT_TIMEOUT_S, **kwargs, ) -> Any: """ diff --git a/packages/service-library/src/servicelib/rabbitmq/_constants.py b/packages/service-library/src/servicelib/rabbitmq/_constants.py index 6238aa2a092..765ebee6e47 100644 --- a/packages/service-library/src/servicelib/rabbitmq/_constants.py +++ b/packages/service-library/src/servicelib/rabbitmq/_constants.py @@ -1,4 +1,7 @@ from typing import Final +from pydantic import PositiveInt + BIND_TO_ALL_TOPICS: Final[str] = "#" +RPC_REQUEST_DEFAULT_TIMEOUT_S: Final[PositiveInt] = PositiveInt(5) RPC_REMOTE_METHOD_TIMEOUT_S: Final[int] = 30 diff --git a/packages/service-library/src/servicelib/rabbitmq/_rpc_router.py b/packages/service-library/src/servicelib/rabbitmq/_rpc_router.py index d0c4a7c41ad..878af76c5dc 100644 --- a/packages/service-library/src/servicelib/rabbitmq/_rpc_router.py +++ b/packages/service-library/src/servicelib/rabbitmq/_rpc_router.py @@ -6,48 +6,73 @@ from typing import Any, TypeVar from models_library.rabbitmq_basic_types import RPCMethodName -from pydantic import SecretStr from ..logging_utils import log_context from ._errors import RPCServerError DecoratedCallable = TypeVar("DecoratedCallable", bound=Callable[..., Any]) +# NOTE: this is equivalent to http access logs _logger = logging.getLogger("rpc.access") -_RPC_CUSTOM_ENCODER: dict[Any, Callable[[Any], Any]] = { - SecretStr: SecretStr.get_secret_value -} + +def _create_func_msg(func, args: tuple[Any, ...], kwargs: dict[str, Any]) -> str: + msg = f"{func.__name__}(" + + if args_msg := ", ".join(map(str, args)): + msg += args_msg + + if kwargs_msg := ", ".join({f"{name}={value}" for name, value in kwargs.items()}): + if args: + msg += ", " + msg += kwargs_msg + + return f"{msg})" @dataclass class RPCRouter: routes: dict[RPCMethodName, Callable] = field(default_factory=dict) - def expose(self) -> Callable[[DecoratedCallable], DecoratedCallable]: - def decorator(func: DecoratedCallable) -> DecoratedCallable: + def expose( + self, + *, + reraise_if_error_type: tuple[type[Exception], ...] | None = None, + ) -> Callable[[DecoratedCallable], DecoratedCallable]: + def _decorator(func: DecoratedCallable) -> DecoratedCallable: @functools.wraps(func) - async def wrapper(*args, **kwargs): + async def _wrapper(*args, **kwargs): + with log_context( + # NOTE: this is intentionally analogous to the http access log traces. + # To change log-level use getLogger("rpc.access").set_level(...) _logger, logging.INFO, - msg=f"calling {func.__name__} with {args}, {kwargs}", + msg=f"RPC call {_create_func_msg(func, args, kwargs)}", + log_duration=True, ): try: return await func(*args, **kwargs) + except asyncio.CancelledError: _logger.debug("call was cancelled") raise + except Exception as exc: # pylint: disable=broad-except + if reraise_if_error_type and isinstance( + exc, reraise_if_error_type + ): + raise + _logger.exception("Unhandled exception:") # NOTE: we do not return internal exceptions over RPC raise RPCServerError( method_name=func.__name__, - exc_type=f"{type(exc)}", + exc_type=f"{exc.__class__.__module__}.{exc.__class__.__name__}", msg=f"{exc}", ) from None - self.routes[RPCMethodName(func.__name__)] = wrapper + self.routes[RPCMethodName(func.__name__)] = _wrapper return func - return decorator + return _decorator diff --git a/packages/service-library/tests/fastapi/test_http_client.py b/packages/service-library/tests/fastapi/test_http_client.py index fb5601c3dbc..10f4987cc1f 100644 --- a/packages/service-library/tests/fastapi/test_http_client.py +++ b/packages/service-library/tests/fastapi/test_http_client.py @@ -13,11 +13,12 @@ from asgi_lifespan import LifespanManager from fastapi import FastAPI, status from models_library.healthchecks import IsResponsive -from servicelib.fastapi.http_client import AppStateMixin, BaseHttpApi, to_curl_command +from servicelib.fastapi.app_state import SingletonInAppStateMixin +from servicelib.fastapi.http_client import BaseHttpApi def test_using_app_state_mixin(): - class SomeData(AppStateMixin): + class SomeData(SingletonInAppStateMixin): app_state_name: str = "my_data" frozen: bool = True @@ -70,7 +71,7 @@ def mock_server_api(base_url: str) -> Iterator[respx.MockRouter]: async def test_base_http_api(mock_server_api: respx.MockRouter, base_url: str): - class MyClientApi(BaseHttpApi, AppStateMixin): + class MyClientApi(BaseHttpApi, SingletonInAppStateMixin): app_state_name: str = "my_client_api" new_app = FastAPI() @@ -106,51 +107,3 @@ class MyClientApi(BaseHttpApi, AppStateMixin): # shutdown event assert api.client.is_closed - - -async def test_to_curl_command(mock_server_api: respx.MockRouter, base_url: str): - - mock_server_api.post(path__startswith="/foo").respond(status.HTTP_200_OK) - mock_server_api.get(path__startswith="/foo").respond(status.HTTP_200_OK) - mock_server_api.delete(path__startswith="/foo").respond(status.HTTP_200_OK) - - async with httpx.AsyncClient(base_url=base_url) as client: - response = await client.post( - "/foo", - params={"x": "3"}, - json={"y": 12}, - headers={"x-secret": "this should not display"}, - ) - assert response.status_code == 200 - - cmd_short = to_curl_command(response.request) - - assert ( - cmd_short - == 'curl -X POST -H "host: test_base_http_api" -H "accept: */*" -H "accept-encoding: gzip, deflate" -H "connection: keep-alive" -H "user-agent: python-httpx/0.25.0" -H "x-secret: *****" -H "content-length: 9" -H "content-type: application/json" -d \'{"y": 12}\' https://test_base_http_api/foo?x=3' - ) - - cmd_long = to_curl_command(response.request, use_short_options=False) - assert cmd_long == cmd_short.replace("-X", "--request",).replace( - "-H", - "--header", - ).replace( - "-d", - "--data", - ) - - # with GET - response = await client.get("/foo", params={"x": "3"}) - cmd_long = to_curl_command(response.request) - - assert ( - cmd_long - == 'curl -X GET -H "host: test_base_http_api" -H "accept: */*" -H "accept-encoding: gzip, deflate" -H "connection: keep-alive" -H "user-agent: python-httpx/0.25.0" https://test_base_http_api/foo?x=3' - ) - - # with DELETE - response = await client.delete("/foo", params={"x": "3"}) - cmd_long = to_curl_command(response.request) - - assert "DELETE" in cmd_long - assert " -d " not in cmd_long diff --git a/packages/service-library/tests/fastapi/test_httpx_utils.py b/packages/service-library/tests/fastapi/test_httpx_utils.py new file mode 100644 index 00000000000..a8d3de6f2e2 --- /dev/null +++ b/packages/service-library/tests/fastapi/test_httpx_utils.py @@ -0,0 +1,118 @@ +# pylint: disable=protected-access +# pylint: disable=redefined-outer-name +# pylint: disable=too-many-arguments +# pylint: disable=unused-argument +# pylint: disable=unused-variable + + +import textwrap +from collections.abc import AsyncIterator, Iterator + +import httpx +import pytest +import respx +from fastapi import status +from httpx import AsyncClient +from servicelib.fastapi.httpx_utils import to_curl_command, to_httpx_command + + +@pytest.fixture +def base_url() -> str: + return "https://test_base_http_api" + + +@pytest.fixture +def mock_server_api(base_url: str) -> Iterator[respx.MockRouter]: + with respx.mock( + base_url=base_url, + assert_all_called=False, + assert_all_mocked=True, # IMPORTANT: KEEP always True! + ) as mock: + mock.get("/").respond(status.HTTP_200_OK) + + mock.post(path__startswith="/foo").respond(status.HTTP_200_OK) + mock.get(path__startswith="/foo").respond(status.HTTP_200_OK) + mock.delete(path__startswith="/foo").respond(status.HTTP_200_OK) + + yield mock + + +@pytest.fixture +async def client( + mock_server_api: respx.MockRouter, base_url: str +) -> AsyncIterator[AsyncClient]: + async with httpx.AsyncClient(base_url=base_url) as client: + + yield client + + +async def test_to_curl_command(client: AsyncClient): + + # with POST + response = await client.post( + "/foo", + params={"x": "3"}, + json={"y": 12}, + headers={"x-secret": "this should not display"}, + ) + assert response.status_code == 200 + + cmd_short = to_curl_command(response.request) + + assert ( + cmd_short + == 'curl -X POST -H "host: test_base_http_api" -H "accept: */*" -H "accept-encoding: gzip, deflate" -H "connection: keep-alive" -H "user-agent: python-httpx/0.25.0" -H "x-secret: *****" -H "content-length: 9" -H "content-type: application/json" -d \'{"y": 12}\' https://test_base_http_api/foo?x=3' + ) + + cmd_long = to_curl_command(response.request, use_short_options=False) + assert cmd_long == cmd_short.replace("-X", "--request",).replace( + "-H", + "--header", + ).replace( + "-d", + "--data", + ) + + # with GET + response = await client.get("/foo", params={"x": "3"}) + cmd_multiline = to_curl_command(response.request, multiline=True) + + assert ( + cmd_multiline + == textwrap.dedent( + """\ + curl \\ + -X GET \\ + -H "host: test_base_http_api" \\ + -H "accept: */*" \\ + -H "accept-encoding: gzip, deflate" \\ + -H "connection: keep-alive" \\ + -H "user-agent: python-httpx/0.25.0" \\ + https://test_base_http_api/foo?x=3 + """ + ).strip() + ) + + # with DELETE + response = await client.delete("/foo", params={"x": "3"}) + cmd = to_curl_command(response.request) + + assert "DELETE" in cmd + assert " -d " not in cmd + + +async def test_to_httpx_command(client: AsyncClient): + response = await client.post( + "/foo", + params={"x": "3"}, + json={"y": 12}, + headers={"x-secret": "this should not display"}, + ) + + cmd_short = to_httpx_command(response.request, multiline=False) + + print(cmd_short) + assert ( + cmd_short + == 'httpx -m POST -c \'{"y": 12}\' -h "host" "test_base_http_api" -h "accept" "*/*" -h "accept-encoding" "gzip, deflate" -h "connection" "keep-alive" -h "user-agent" "python-httpx/0.25.0" -h "x-secret" "*****" -h "content-length" "9" -h "content-type" "application/json" https://test_base_http_api/foo?x=3' + ) diff --git a/packages/service-library/tests/rabbitmq/test_rabbitmq_rpc_router.py b/packages/service-library/tests/rabbitmq/test_rabbitmq_rpc_router.py index 67f6c92e6d2..570206c6c9f 100644 --- a/packages/service-library/tests/rabbitmq/test_rabbitmq_rpc_router.py +++ b/packages/service-library/tests/rabbitmq/test_rabbitmq_rpc_router.py @@ -6,7 +6,12 @@ import pytest from faker import Faker from models_library.rabbitmq_basic_types import RPCMethodName -from servicelib.rabbitmq import RabbitMQRPCClient, RPCNamespace, RPCRouter +from servicelib.rabbitmq import ( + RabbitMQRPCClient, + RPCNamespace, + RPCRouter, + RPCServerError, +) pytest_simcore_core_services_selection = [ "rabbit", @@ -16,6 +21,14 @@ router = RPCRouter() +class MyBaseError(Exception): + ... + + +class MyExpectedError(MyBaseError): + ... + + @router.expose() async def a_str_method( a_global_arg: str, *, a_global_kwarg: str, a_specific_kwarg: str @@ -28,10 +41,16 @@ async def an_int_method(a_global_arg: str, *, a_global_kwarg: str) -> int: return 34 +@router.expose(reraise_if_error_type=(MyBaseError,)) +async def raising_expected_error(a_global_arg: str, *, a_global_kwarg: str) -> int: + msg = "This could happen" + raise MyExpectedError(msg) + + @router.expose() -async def a_raising_method(a_global_arg: str, *, a_global_kwarg: str) -> int: +async def raising_unexpected_error(a_global_arg: str, *, a_global_kwarg: str) -> int: msg = "This is not good!" - raise RuntimeError(msg) + raise ValueError(msg) @pytest.fixture @@ -71,8 +90,18 @@ async def test_exposed_methods( result = rpc_result assert result == 34 - with pytest.raises(RuntimeError): + # unexpected errors are turned into RPCServerError + with pytest.raises(RPCServerError) as exc_info: await rpc_client.request( router_namespace, - RPCMethodName(a_raising_method.__name__), + RPCMethodName(raising_unexpected_error.__name__), ) + + # This error was classified int he interface + with pytest.raises(MyBaseError) as exc_info: + await rpc_client.request( + router_namespace, + RPCMethodName(raising_expected_error.__name__), + ) + + assert isinstance(exc_info.value, MyExpectedError) diff --git a/services/payments/src/simcore_service_payments/api/rest/_acknowledgements.py b/services/payments/src/simcore_service_payments/api/rest/_acknowledgements.py index ec458bd8c5c..623e49276e7 100644 --- a/services/payments/src/simcore_service_payments/api/rest/_acknowledgements.py +++ b/services/payments/src/simcore_service_payments/api/rest/_acknowledgements.py @@ -2,10 +2,13 @@ from typing import Annotated from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status +from models_library.api_schemas_payments.errors import ( + PaymentMethodNotFoundError, + PaymentNotFoundError, +) from servicelib.logging_utils import log_context from ..._constants import ACKED, PGDB -from ...core.errors import PaymentMethodNotFoundError, PaymentNotFoundError from ...db.payments_methods_repo import PaymentsMethodsRepo from ...db.payments_transactions_repo import PaymentsTransactionsRepo from ...models.auth import SessionData diff --git a/services/payments/src/simcore_service_payments/api/rest/_dependencies.py b/services/payments/src/simcore_service_payments/api/rest/_dependencies.py index e59a9766266..b0e3ba78a4e 100644 --- a/services/payments/src/simcore_service_payments/api/rest/_dependencies.py +++ b/services/payments/src/simcore_service_payments/api/rest/_dependencies.py @@ -4,8 +4,8 @@ from fastapi import Depends, FastAPI, Request from fastapi.security import OAuth2PasswordBearer +from servicelib.fastapi.app_state import SingletonInAppStateMixin from servicelib.fastapi.dependencies import get_app, get_reverse_url_mapper -from servicelib.fastapi.http_client import AppStateMixin from sqlalchemy.ext.asyncio import AsyncEngine from ..._meta import API_VTAG @@ -44,7 +44,9 @@ def get_rut_api(request: Request) -> ResourceUsageTrackerApi: ) -def get_from_app_state(app_state_mixin_subclass: type[AppStateMixin]) -> Callable: +def get_from_app_state( + app_state_mixin_subclass: type[SingletonInAppStateMixin], +) -> Callable: """Generic getter of app.state objects""" def _(app: Annotated[FastAPI, Depends(get_app)]): diff --git a/services/payments/src/simcore_service_payments/api/rpc/_payments.py b/services/payments/src/simcore_service_payments/api/rpc/_payments.py index 550f5fb6977..f66ffe08366 100644 --- a/services/payments/src/simcore_service_payments/api/rpc/_payments.py +++ b/services/payments/src/simcore_service_payments/api/rpc/_payments.py @@ -2,6 +2,10 @@ from decimal import Decimal from fastapi import FastAPI +from models_library.api_schemas_payments.errors import ( + PaymentsError, + PaymentServiceUnavailableError, +) from models_library.api_schemas_webserver.wallets import ( PaymentID, PaymentTransaction, @@ -23,7 +27,7 @@ router = RPCRouter() -@router.expose() +@router.expose(reraise_if_error_type=(PaymentsError, PaymentServiceUnavailableError)) async def init_payment( app: FastAPI, *, @@ -60,7 +64,7 @@ async def init_payment( ) -@router.expose() +@router.expose(reraise_if_error_type=(PaymentsError, PaymentServiceUnavailableError)) async def cancel_payment( app: FastAPI, *, @@ -85,7 +89,7 @@ async def cancel_payment( ) -@router.expose() +@router.expose(reraise_if_error_type=(PaymentsError, PaymentServiceUnavailableError)) async def get_payments_page( app: FastAPI, *, diff --git a/services/payments/src/simcore_service_payments/api/rpc/_payments_methods.py b/services/payments/src/simcore_service_payments/api/rpc/_payments_methods.py index 9d959374291..87daecd7fb6 100644 --- a/services/payments/src/simcore_service_payments/api/rpc/_payments_methods.py +++ b/services/payments/src/simcore_service_payments/api/rpc/_payments_methods.py @@ -2,6 +2,11 @@ from decimal import Decimal from fastapi import FastAPI +from models_library.api_schemas_payments.errors import ( + PaymentsError, + PaymentServiceUnavailableError, + PaymentsMethodsError, +) from models_library.api_schemas_webserver.wallets import ( PaymentMethodGet, PaymentMethodID, @@ -26,7 +31,9 @@ router = RPCRouter() -@router.expose() +@router.expose( + reraise_if_error_type=(PaymentsMethodsError, PaymentServiceUnavailableError) +) async def init_creation_of_payment_method( app: FastAPI, *, @@ -54,7 +61,9 @@ async def init_creation_of_payment_method( ) -@router.expose() +@router.expose( + reraise_if_error_type=(PaymentsMethodsError, PaymentServiceUnavailableError) +) async def cancel_creation_of_payment_method( app: FastAPI, *, @@ -78,7 +87,9 @@ async def cancel_creation_of_payment_method( ) -@router.expose() +@router.expose( + reraise_if_error_type=(PaymentsMethodsError, PaymentServiceUnavailableError) +) async def list_payment_methods( app: FastAPI, *, @@ -93,7 +104,9 @@ async def list_payment_methods( ) -@router.expose() +@router.expose( + reraise_if_error_type=(PaymentsMethodsError, PaymentServiceUnavailableError) +) async def get_payment_method( app: FastAPI, *, @@ -110,7 +123,9 @@ async def get_payment_method( ) -@router.expose() +@router.expose( + reraise_if_error_type=(PaymentsMethodsError, PaymentServiceUnavailableError) +) async def delete_payment_method( app: FastAPI, *, @@ -127,7 +142,13 @@ async def delete_payment_method( ) -@router.expose() +@router.expose( + reraise_if_error_type=( + PaymentsMethodsError, + PaymentsError, + PaymentServiceUnavailableError, + ) +) async def pay_with_payment_method( # noqa: PLR0913 # pylint: disable=too-many-arguments app: FastAPI, *, diff --git a/services/payments/src/simcore_service_payments/core/errors.py b/services/payments/src/simcore_service_payments/core/errors.py index 1bc228f173f..3a649829130 100644 --- a/services/payments/src/simcore_service_payments/core/errors.py +++ b/services/payments/src/simcore_service_payments/core/errors.py @@ -1,39 +1,21 @@ from pydantic.errors import PydanticErrorMixin -class PaymentsError(PydanticErrorMixin, ValueError): - msg_template = "Error in payment transaction '{payment_id}'" +class _BaseAppError(PydanticErrorMixin, ValueError): + @classmethod + def get_full_class_name(cls) -> str: + # Can be used as unique code identifier + return f"{cls.__module__}.{cls.__name__}" -class PaymentNotFoundError(PaymentsError): - msg_template = "Payment transaction '{payment_id}' was not found" +# +# gateway errors +# -class PaymentAlreadyExistsError(PaymentsError): - msg_template = "Payment transaction '{payment_id}' was already initialized" - - -class PaymentAlreadyAckedError(PaymentsError): - msg_template = "Payment transaction '{payment_id}' cannot be changes since it was already closed." - - -class PaymentsMethodsError(PaymentsError): +class PaymentsGatewayError(_BaseAppError): ... -class PaymentMethodNotFoundError(PaymentsMethodsError): - msg_template = "The specified payment method '{payment_method_id}' does not exist" - - -class PaymentMethodAlreadyAckedError(PaymentsMethodsError): - msg_template = ( - "Cannot create payment-method '{payment_method_id}' since it was already closed" - ) - - -class PaymentMethodUniqueViolationError(PaymentsMethodsError): - msg_template = "Payment method '{payment_method_id}' aready exists" - - -class InvalidPaymentMethodError(PaymentsMethodsError): - msg_template = "Invalid payment method '{payment_method_id}'" +class PaymentsGatewayNotReadyError(PaymentsGatewayError): + msg_template = "Payments-Gateway is unresponsive: {checks}" diff --git a/services/payments/src/simcore_service_payments/db/auto_recharge_repo.py b/services/payments/src/simcore_service_payments/db/auto_recharge_repo.py index d4bc5634e54..4e7b25d228e 100644 --- a/services/payments/src/simcore_service_payments/db/auto_recharge_repo.py +++ b/services/payments/src/simcore_service_payments/db/auto_recharge_repo.py @@ -1,5 +1,6 @@ from typing import TypeAlias +from models_library.api_schemas_payments.errors import InvalidPaymentMethodError from models_library.api_schemas_webserver.wallets import PaymentMethodID from models_library.basic_types import NonNegativeDecimal from models_library.users import UserID @@ -7,7 +8,6 @@ from pydantic import BaseModel, PositiveInt from simcore_postgres_database.utils_payments_autorecharge import AutoRechargeStmts -from ..core.errors import InvalidPaymentMethodError from .base import BaseRepository AutoRechargeID: TypeAlias = PositiveInt diff --git a/services/payments/src/simcore_service_payments/db/payments_methods_repo.py b/services/payments/src/simcore_service_payments/db/payments_methods_repo.py index f1c3f791efd..79e4b6d7ae4 100644 --- a/services/payments/src/simcore_service_payments/db/payments_methods_repo.py +++ b/services/payments/src/simcore_service_payments/db/payments_methods_repo.py @@ -3,6 +3,11 @@ import simcore_postgres_database.errors as db_errors import sqlalchemy as sa from arrow import utcnow +from models_library.api_schemas_payments.errors import ( + PaymentMethodAlreadyAckedError, + PaymentMethodNotFoundError, + PaymentMethodUniqueViolationError, +) from models_library.api_schemas_webserver.wallets import PaymentMethodID from models_library.users import UserID from models_library.wallets import WalletID @@ -12,11 +17,6 @@ payments_methods, ) -from ..core.errors import ( - PaymentMethodAlreadyAckedError, - PaymentMethodNotFoundError, - PaymentMethodUniqueViolationError, -) from ..models.db import PaymentsMethodsDB from .base import BaseRepository diff --git a/services/payments/src/simcore_service_payments/db/payments_transactions_repo.py b/services/payments/src/simcore_service_payments/db/payments_transactions_repo.py index 84ee8b8543c..fc4f8946d0e 100644 --- a/services/payments/src/simcore_service_payments/db/payments_transactions_repo.py +++ b/services/payments/src/simcore_service_payments/db/payments_transactions_repo.py @@ -2,6 +2,11 @@ from decimal import Decimal import sqlalchemy as sa +from models_library.api_schemas_payments.errors import ( + PaymentAlreadyAckedError, + PaymentAlreadyExistsError, + PaymentNotFoundError, +) from models_library.api_schemas_webserver.wallets import PaymentID from models_library.users import UserID from models_library.wallets import WalletID @@ -12,11 +17,6 @@ payments_transactions, ) -from ..core.errors import ( - PaymentAlreadyAckedError, - PaymentAlreadyExistsError, - PaymentNotFoundError, -) from ..models.db import PaymentsTransactionsDB from .base import BaseRepository diff --git a/services/payments/src/simcore_service_payments/services/notifier.py b/services/payments/src/simcore_service_payments/services/notifier.py index ccee5d33a74..e4cd5bd8ef8 100644 --- a/services/payments/src/simcore_service_payments/services/notifier.py +++ b/services/payments/src/simcore_service_payments/services/notifier.py @@ -13,7 +13,7 @@ PaymentTransaction, ) from models_library.users import UserID -from servicelib.fastapi.http_client import AppStateMixin +from servicelib.fastapi.app_state import SingletonInAppStateMixin from ..db.payment_users_repo import PaymentsUsersRepo from .postgres import get_engine @@ -21,7 +21,7 @@ _logger = logging.getLogger(__name__) -class Notifier(AppStateMixin): +class Notifier(SingletonInAppStateMixin): app_state_name: str = "notifier" def __init__( diff --git a/services/payments/src/simcore_service_payments/services/payments.py b/services/payments/src/simcore_service_payments/services/payments.py index a218a1034bb..bcd6083bde3 100644 --- a/services/payments/src/simcore_service_payments/services/payments.py +++ b/services/payments/src/simcore_service_payments/services/payments.py @@ -10,6 +10,11 @@ from decimal import Decimal import arrow +from models_library.api_schemas_payments.errors import ( + PaymentAlreadyAckedError, + PaymentAlreadyExistsError, + PaymentNotFoundError, +) from models_library.api_schemas_webserver.wallets import ( PaymentID, PaymentMethodID, @@ -29,11 +34,6 @@ from tenacity.stop import stop_after_attempt from .._constants import RUT -from ..core.errors import ( - PaymentAlreadyAckedError, - PaymentAlreadyExistsError, - PaymentNotFoundError, -) from ..db.payments_transactions_repo import PaymentsTransactionsRepo from ..models.db import PaymentsTransactionsDB from ..models.db_to_api import to_payments_api_model diff --git a/services/payments/src/simcore_service_payments/services/payments_gateway.py b/services/payments/src/simcore_service_payments/services/payments_gateway.py index 3004a6709c6..51781a52ede 100644 --- a/services/payments/src/simcore_service_payments/services/payments_gateway.py +++ b/services/payments/src/simcore_service_payments/services/payments_gateway.py @@ -18,7 +18,9 @@ from models_library.api_schemas_webserver.wallets import PaymentID, PaymentMethodID from pydantic import ValidationError, parse_raw_as from pydantic.errors import PydanticErrorMixin -from servicelib.fastapi.http_client import AppStateMixin, BaseHttpApi, to_curl_command +from servicelib.fastapi.app_state import SingletonInAppStateMixin +from servicelib.fastapi.http_client import BaseHttpApi +from servicelib.fastapi.httpx_utils import to_curl_command from simcore_service_payments.models.schemas.acknowledgements import ( AckPaymentWithPaymentMethod, ) @@ -105,7 +107,7 @@ def auth_flow(self, request): yield request -class PaymentsGatewayApi(BaseHttpApi, AppStateMixin): +class PaymentsGatewayApi(BaseHttpApi, SingletonInAppStateMixin): app_state_name: str = "payment_gateway_api" # diff --git a/services/payments/src/simcore_service_payments/services/resource_usage_tracker.py b/services/payments/src/simcore_service_payments/services/resource_usage_tracker.py index a2ecdee7757..6fe9dc186d3 100644 --- a/services/payments/src/simcore_service_payments/services/resource_usage_tracker.py +++ b/services/payments/src/simcore_service_payments/services/resource_usage_tracker.py @@ -19,14 +19,15 @@ from models_library.resource_tracker import CreditTransactionId from models_library.users import UserID from models_library.wallets import WalletID -from servicelib.fastapi.http_client import AppStateMixin, BaseHttpApi +from servicelib.fastapi.app_state import SingletonInAppStateMixin +from servicelib.fastapi.http_client import BaseHttpApi from ..core.settings import ApplicationSettings _logger = logging.getLogger(__name__) -class ResourceUsageTrackerApi(BaseHttpApi, AppStateMixin): +class ResourceUsageTrackerApi(BaseHttpApi, SingletonInAppStateMixin): app_state_name: str = "source_usage_tracker_api" async def create_credit_transaction( diff --git a/services/payments/tests/unit/api/test__one_time_payment_workflows.py b/services/payments/tests/unit/api/test__one_time_payment_workflows.py index f4697b7ab3d..57ca6594e94 100644 --- a/services/payments/tests/unit/api/test__one_time_payment_workflows.py +++ b/services/payments/tests/unit/api/test__one_time_payment_workflows.py @@ -19,6 +19,7 @@ from pytest_simcore.helpers.utils_envs import setenvs_from_dict from respx import MockRouter from servicelib.rabbitmq import RabbitMQRPCClient +from servicelib.rabbitmq._constants import RPC_REQUEST_DEFAULT_TIMEOUT_S from simcore_service_payments.api.rpc.routes import PAYMENTS_RPC_NAMESPACE from simcore_service_payments.models.schemas.acknowledgements import AckPayment @@ -93,7 +94,7 @@ async def test_successful_one_time_payment_workflow( user_id=user_id, user_name=user_name, user_email=user_email, - timeout_s=None if is_pdb_enabled else 5, + timeout_s=None if is_pdb_enabled else RPC_REQUEST_DEFAULT_TIMEOUT_S, ) assert isinstance(inited, WalletPaymentInitiated) @@ -114,7 +115,7 @@ async def test_successful_one_time_payment_workflow( PAYMENTS_RPC_NAMESPACE, parse_obj_as(RPCMethodName, "get_payments_page"), user_id=user_id, - timeout_s=None if is_pdb_enabled else 5, + timeout_s=None if is_pdb_enabled else RPC_REQUEST_DEFAULT_TIMEOUT_S, ) total_number_of_items, transactions = got diff --git a/services/payments/tests/unit/api/test__payment_method_workflows.py b/services/payments/tests/unit/api/test__payment_method_workflows.py index c1a60bc5555..15cedd186d9 100644 --- a/services/payments/tests/unit/api/test__payment_method_workflows.py +++ b/services/payments/tests/unit/api/test__payment_method_workflows.py @@ -22,6 +22,7 @@ from pytest_simcore.helpers.utils_envs import setenvs_from_dict from respx import MockRouter from servicelib.rabbitmq import RabbitMQRPCClient +from servicelib.rabbitmq._constants import RPC_REQUEST_DEFAULT_TIMEOUT_S from simcore_service_payments.api.rpc.routes import PAYMENTS_RPC_NAMESPACE from simcore_service_payments.models.schemas.acknowledgements import AckPayment @@ -93,7 +94,7 @@ async def test_successful_create_payment_method_workflow( user_id=user_id, user_name=user_name, user_email=user_email, - timeout_s=None if is_pdb_enabled else 5, + timeout_s=None if is_pdb_enabled else RPC_REQUEST_DEFAULT_TIMEOUT_S, ) assert isinstance(inited, PaymentMethodInitiated) diff --git a/services/payments/tests/unit/api/test_rest_acknowledgements.py b/services/payments/tests/unit/api/test_rest_acknowledgements.py index 3d2c085dfec..769ddb7dc2e 100644 --- a/services/payments/tests/unit/api/test_rest_acknowledgements.py +++ b/services/payments/tests/unit/api/test_rest_acknowledgements.py @@ -11,13 +11,13 @@ import pytest from faker import Faker from fastapi import FastAPI, status -from pytest_mock import MockerFixture -from pytest_simcore.helpers.typing_env import EnvVarsDict -from pytest_simcore.helpers.utils_envs import setenvs_from_dict -from simcore_service_payments.core.errors import ( +from models_library.api_schemas_payments.errors import ( PaymentMethodNotFoundError, PaymentNotFoundError, ) +from pytest_mock import MockerFixture +from pytest_simcore.helpers.typing_env import EnvVarsDict +from pytest_simcore.helpers.utils_envs import setenvs_from_dict from simcore_service_payments.models.schemas.acknowledgements import ( AckPayment, AckPaymentMethod, diff --git a/services/payments/tests/unit/test_rpc_payments.py b/services/payments/tests/unit/test_rpc_payments.py index 7e2872c2ffd..b99f99821fa 100644 --- a/services/payments/tests/unit/test_rpc_payments.py +++ b/services/payments/tests/unit/test_rpc_payments.py @@ -6,10 +6,10 @@ from typing import Any -import httpx import pytest from faker import Faker from fastapi import FastAPI +from models_library.api_schemas_payments.errors import PaymentNotFoundError from models_library.api_schemas_webserver.wallets import WalletPaymentInitiated from models_library.rabbitmq_basic_types import RPCMethodName from pydantic import parse_obj_as @@ -17,8 +17,8 @@ from pytest_simcore.helpers.utils_envs import setenvs_from_dict from respx import MockRouter from servicelib.rabbitmq import RabbitMQRPCClient, RPCServerError +from servicelib.rabbitmq._constants import RPC_REQUEST_DEFAULT_TIMEOUT_S from simcore_service_payments.api.rpc.routes import PAYMENTS_RPC_NAMESPACE -from simcore_service_payments.core.errors import PaymentNotFoundError pytest_simcore_core_services_selection = [ "postgres", @@ -83,13 +83,15 @@ async def test_rpc_init_payment_fail( **init_payment_kwargs, ) - exc = exc_info.value - assert exc.exc_type == f"{httpx.ConnectError}" - assert exc.method_name == "init_payment" - assert exc.msg + error = exc_info.value + assert isinstance(error, RPCServerError) + assert error.exc_type == "httpx.ConnectError" + assert error.method_name == "init_payment" + assert error.msg async def test_webserver_one_time_payment_workflow( + is_pdb_enabled: bool, app: FastAPI, rpc_client: RabbitMQRPCClient, mock_payments_gateway_service_or_none: MockRouter | None, @@ -115,7 +117,7 @@ async def test_webserver_one_time_payment_workflow( payment_id=result.payment_id, user_id=init_payment_kwargs["user_id"], wallet_id=init_payment_kwargs["wallet_id"], - timeout_s=20, # for tests + timeout_s=None if is_pdb_enabled else RPC_REQUEST_DEFAULT_TIMEOUT_S, ) assert result is None @@ -125,6 +127,8 @@ async def test_webserver_one_time_payment_workflow( async def test_cancel_invalid_payment_id( + is_pdb_enabled: bool, + app: FastAPI, rpc_client: RabbitMQRPCClient, mock_payments_gateway_service_or_none: MockRouter | None, init_payment_kwargs: dict[str, Any], @@ -133,20 +137,14 @@ async def test_cancel_invalid_payment_id( ): invalid_payment_id = faker.uuid4() - with pytest.raises(RPCServerError) as exc_info: + with pytest.raises(PaymentNotFoundError) as exc_info: await rpc_client.request( PAYMENTS_RPC_NAMESPACE, parse_obj_as(RPCMethodName, "cancel_payment"), payment_id=invalid_payment_id, user_id=init_payment_kwargs["user_id"], wallet_id=init_payment_kwargs["wallet_id"], - timeout_s=20, # for tests + timeout_s=None if is_pdb_enabled else RPC_REQUEST_DEFAULT_TIMEOUT_S, ) error = exc_info.value - - assert isinstance(error, RPCServerError) - assert error.exc_type == f"{PaymentNotFoundError}" - assert error.method_name == "cancel_payment" - assert error.msg == PaymentNotFoundError.msg_template.format( - payment_id=invalid_payment_id - ) + assert isinstance(error, PaymentNotFoundError) diff --git a/services/payments/tests/unit/test_rpc_payments_methods.py b/services/payments/tests/unit/test_rpc_payments_methods.py index ec42cfe1f12..61816ef633f 100644 --- a/services/payments/tests/unit/test_rpc_payments_methods.py +++ b/services/payments/tests/unit/test_rpc_payments_methods.py @@ -22,6 +22,7 @@ from pytest_simcore.helpers.utils_envs import setenvs_from_dict from respx import MockRouter from servicelib.rabbitmq import RabbitMQRPCClient +from servicelib.rabbitmq._constants import RPC_REQUEST_DEFAULT_TIMEOUT_S from simcore_service_payments.api.rpc.routes import PAYMENTS_RPC_NAMESPACE from simcore_service_payments.db.payments_methods_repo import PaymentsMethodsRepo from simcore_service_payments.db.payments_transactions_repo import ( @@ -105,7 +106,7 @@ async def test_webserver_init_and_cancel_payment_method_workflow( payment_method_id=initiated.payment_method_id, user_id=user_id, wallet_id=wallet_id, - timeout_s=None if is_pdb_enabled else 5, + timeout_s=None if is_pdb_enabled else RPC_REQUEST_DEFAULT_TIMEOUT_S, ) assert cancelled is None @@ -161,7 +162,7 @@ async def test_webserver_crud_payment_method_workflow( parse_obj_as(RPCMethodName, "list_payment_methods"), user_id=user_id, wallet_id=wallet_id, - timeout_s=None if is_pdb_enabled else 5, + timeout_s=None if is_pdb_enabled else RPC_REQUEST_DEFAULT_TIMEOUT_S, ) assert len(listed) == 1 @@ -176,7 +177,7 @@ async def test_webserver_crud_payment_method_workflow( payment_method_id=inited.payment_method_id, user_id=user_id, wallet_id=wallet_id, - timeout_s=None if is_pdb_enabled else 5, + timeout_s=None if is_pdb_enabled else RPC_REQUEST_DEFAULT_TIMEOUT_S, ) assert got == listed[0] if mock_payments_gateway_service_or_none: @@ -188,7 +189,7 @@ async def test_webserver_crud_payment_method_workflow( payment_method_id=inited.payment_method_id, user_id=user_id, wallet_id=wallet_id, - timeout_s=None if is_pdb_enabled else 5, + timeout_s=None if is_pdb_enabled else RPC_REQUEST_DEFAULT_TIMEOUT_S, ) if mock_payments_gateway_service_or_none: diff --git a/services/web/server/src/simcore_service_webserver/payments/_methods_api.py b/services/web/server/src/simcore_service_webserver/payments/_methods_api.py index 18cd691f75d..a64a97d1d7b 100644 --- a/services/web/server/src/simcore_service_webserver/payments/_methods_api.py +++ b/services/web/server/src/simcore_service_webserver/payments/_methods_api.py @@ -16,6 +16,7 @@ from models_library.products import ProductName from models_library.users import UserID from models_library.wallets import WalletID +from servicelib.logging_utils import log_decorator from simcore_postgres_database.models.payments_methods import InitPromptAckFlowState from yarl import URL @@ -65,6 +66,7 @@ def _to_api_model( ) +@log_decorator(_logger, level=logging.INFO) async def _fake_init_creation_of_wallet_payment_method( app, settings, user_id, wallet_id ): @@ -131,6 +133,7 @@ async def _ack_creation_of_wallet_payment_method( return updated +@log_decorator(_logger, level=logging.INFO) async def _fake_cancel_creation_of_wallet_payment_method( app, payment_method_id, user_id, wallet_id ): @@ -156,6 +159,7 @@ async def _fake_cancel_creation_of_wallet_payment_method( ) +@log_decorator(_logger, level=logging.INFO) async def _fake_list_wallet_payment_methods( app, user_id, wallet_id ) -> list[PaymentMethodGet]: @@ -190,6 +194,7 @@ async def _fake_list_wallet_payment_methods( return payments_methods +@log_decorator(_logger, level=logging.INFO) async def _fake_get_wallet_payment_method(app, user_id, wallet_id, payment_method_id): acked = await get_successful_payment_method( app, @@ -214,6 +219,7 @@ async def _fake_get_wallet_payment_method(app, user_id, wallet_id, payment_metho ) +@log_decorator(_logger, level=logging.INFO) async def _fake_delete_wallet_payment_method( app, user_id, wallet_id, payment_method_id ) -> None: @@ -322,6 +328,7 @@ async def list_wallet_payment_methods( app, user_id=user_id, wallet_id=wallet_id, product_name=product_name ) + payments_methods: list[PaymentMethodGet] = [] settings: PaymentsSettings = get_plugin_settings(app) if settings.PAYMENTS_FAKE_COMPLETION: payments_methods = await _fake_list_wallet_payment_methods( @@ -335,7 +342,10 @@ async def list_wallet_payment_methods( wallet_id=wallet_id, ) + # sets auto-recharge flag + assert all(not pm.auto_recharge for pm in payments_methods) # nosec if auto_rechage := await get_wallet_autorecharge(app, wallet_id=wallet_id): + assert payments_methods # nosec for pm in payments_methods: pm.auto_recharge = pm.idr == auto_rechage.primary_payment_method_id diff --git a/services/web/server/src/simcore_service_webserver/payments/_onetime_api.py b/services/web/server/src/simcore_service_webserver/payments/_onetime_api.py index 8c4b74d9f19..d6994f8409a 100644 --- a/services/web/server/src/simcore_service_webserver/payments/_onetime_api.py +++ b/services/web/server/src/simcore_service_webserver/payments/_onetime_api.py @@ -15,6 +15,7 @@ from models_library.users import UserID from models_library.wallets import WalletID from pydantic import HttpUrl +from servicelib.logging_utils import log_decorator from simcore_postgres_database.models.payments_transactions import ( PaymentTransactionState, ) @@ -62,6 +63,7 @@ def _to_api_model( return PaymentTransaction.parse_obj(data) +@log_decorator(_logger, level=logging.INFO) async def _fake_init_payment( app, amount_dollars, @@ -154,6 +156,7 @@ async def _ack_creation_of_wallet_payment( return payment +@log_decorator(_logger, level=logging.INFO) async def _fake_cancel_payment(app, payment_id) -> None: await _ack_creation_of_wallet_payment( app, @@ -163,6 +166,7 @@ async def _fake_cancel_payment(app, payment_id) -> None: ) +@log_decorator(_logger, level=logging.INFO) async def _fake_pay_with_payment_method( # noqa: PLR0913 pylint: disable=too-many-arguments app, amount_dollars, @@ -200,6 +204,7 @@ async def _fake_pay_with_payment_method( # noqa: PLR0913 pylint: disable=too-ma ) +@log_decorator(_logger, level=logging.INFO) async def _fake_get_payments_page( app: web.Application, user_id: UserID, diff --git a/services/web/server/src/simcore_service_webserver/payments/_tasks.py b/services/web/server/src/simcore_service_webserver/payments/_tasks.py index 6e9b798165e..d6c8a5719fb 100644 --- a/services/web/server/src/simcore_service_webserver/payments/_tasks.py +++ b/services/web/server/src/simcore_service_webserver/payments/_tasks.py @@ -8,6 +8,7 @@ from models_library.api_schemas_webserver.wallets import PaymentID, PaymentMethodID from pydantic import HttpUrl, parse_obj_as from servicelib.aiohttp.typing_extension import CleanupContextFunc +from servicelib.logging_utils import log_decorator from simcore_postgres_database.models.payments_methods import InitPromptAckFlowState from simcore_postgres_database.models.payments_transactions import ( PaymentTransactionState, @@ -33,6 +34,15 @@ _APP_TASK_KEY = f"{_PERIODIC_TASK_NAME}.task" +async def _check_and_sleep(app: web.Application): + settings = get_plugin_settings(app) + if not settings.PAYMENTS_FAKE_COMPLETION: + msg = "PAYMENTS_FAKE_COMPLETION only allowed FOR TESTING PURPOSES" + raise ValueError(msg) + + await asyncio.sleep(settings.PAYMENTS_FAKE_COMPLETION_DELAY_SEC) + + def _create_possible_outcomes(accepted, rejected): return [*(accepted for _ in range(9)), rejected] @@ -53,17 +63,14 @@ def _create_possible_outcomes(accepted, rejected): ) +@log_decorator(_logger, level=logging.INFO) async def _fake_payment_completion(app: web.Application, payment_id: PaymentID): - # Fakes processing time - settings = get_plugin_settings(app) - assert settings.PAYMENTS_FAKE_COMPLETION # nosec - await asyncio.sleep(settings.PAYMENTS_FAKE_COMPLETION_DELAY_SEC) + await _check_and_sleep(app) kwargs: dict[str, Any] = random.choice( # nosec # noqa: S311 # NOSONAR _POSSIBLE_PAYMENTS_OUTCOMES ) - _logger.info("Faking payment completion as %s", kwargs) await _ack_creation_of_wallet_payment( app, payment_id=payment_id, notify_enabled=True, **kwargs ) @@ -80,19 +87,16 @@ async def _fake_payment_completion(app: web.Application, payment_id: PaymentID): ) +@log_decorator(_logger, level=logging.INFO) async def _fake_payment_method_completion( app: web.Application, payment_method_id: PaymentMethodID ): - # Fakes processing time - settings = get_plugin_settings(app) - assert settings.PAYMENTS_FAKE_COMPLETION # nosec - await asyncio.sleep(settings.PAYMENTS_FAKE_COMPLETION_DELAY_SEC) + await _check_and_sleep(app) kwargs: dict[str, Any] = random.choice( # nosec # noqa: S311 # NOSONAR _POSSIBLE_PAYMENTS_METHODS_OUTCOMES ) - _logger.info("Faking payment-method completion as %s", kwargs) await _ack_creation_of_wallet_payment_method( app, payment_method_id=payment_method_id, **kwargs ) @@ -108,12 +112,14 @@ async def _run_resilient_task(app: web.Application): pending = await get_pending_payment_transactions_ids(app) _logger.debug("Pending payment transactions: %s", pending) if pending: - asyncio.gather(*[_fake_payment_completion(app, id_) for id_ in pending]) + await asyncio.gather(*(_fake_payment_completion(app, id_) for id_ in pending)) pending = await get_pending_payment_methods_ids(app) _logger.debug("Pending payment-methods: %s", pending) if pending: - asyncio.gather(*[_fake_payment_method_completion(app, id_) for id_ in pending]) + await asyncio.gather( + *(_fake_payment_method_completion(app, id_) for id_ in pending) + ) async def _run_periodically(app: web.Application, wait_period_s: float): diff --git a/services/web/server/src/simcore_service_webserver/payments/errors.py b/services/web/server/src/simcore_service_webserver/payments/errors.py index fcd726b9bc7..1cf247aaa65 100644 --- a/services/web/server/src/simcore_service_webserver/payments/errors.py +++ b/services/web/server/src/simcore_service_webserver/payments/errors.py @@ -1,49 +1,30 @@ +from models_library.api_schemas_payments.errors import ( + InvalidPaymentMethodError, + PaymentMethodAlreadyAckedError, + PaymentMethodNotFoundError, + PaymentMethodUniqueViolationError, + PaymentNotFoundError, + PaymentServiceUnavailableError, +) from pydantic.errors import PydanticErrorMixin - -class PaymentsError(PydanticErrorMixin, ValueError): - ... +__all__ = ( + "InvalidPaymentMethodError", + "PaymentMethodAlreadyAckedError", + "PaymentMethodNotFoundError", + "PaymentMethodUniqueViolationError", + "PaymentNotFoundError", + "PaymentServiceUnavailableError", +) -class PaymentNotFoundError(PaymentsError): - msg_template = "Invalid payment identifier '{payment_id}'" +class PaymentsPluginError(PydanticErrorMixin, ValueError): + ... -class PaymentCompletedError(PaymentsError): +class PaymentCompletedError(PaymentsPluginError): msg_template = "Cannot complete payment '{payment_id}' that was already closed" -class PaymentUniqueViolationError(PaymentsError): +class PaymentUniqueViolationError(PaymentsPluginError): msg_template = "Payment transaction '{payment_id}' aready exists" - - -# -# payment methods -# - - -class PaymentsMethodsError(PydanticErrorMixin, ValueError): - ... - - -class PaymentMethodNotFoundError(PaymentsMethodsError): - msg_template = "Cannot find payment method '{payment_method_id}'" - - -class PaymentMethodAlreadyAckedError(PaymentsMethodsError): - msg_template = ( - "Cannot create payment-method '{payment_method_id}' since it was already closed" - ) - - -class PaymentMethodUniqueViolationError(PaymentsMethodsError): - msg_template = "Payment method '{payment_method_id}' aready exists" - - -# -# autorecharge -# - - -class InvalidPaymentMethodError(PaymentsMethodsError): - msg_template = "Invalid payment method '{payment_method_id}'" diff --git a/services/web/server/src/simcore_service_webserver/wallets/_handlers.py b/services/web/server/src/simcore_service_webserver/wallets/_handlers.py index c1d4d8bf154..fea64c7196c 100644 --- a/services/web/server/src/simcore_service_webserver/wallets/_handlers.py +++ b/services/web/server/src/simcore_service_webserver/wallets/_handlers.py @@ -31,6 +31,7 @@ PaymentMethodNotFoundError, PaymentMethodUniqueViolationError, PaymentNotFoundError, + PaymentServiceUnavailableError, PaymentUniqueViolationError, ) from ..products.errors import ProductPriceNotDefinedError @@ -67,11 +68,14 @@ async def wrapper(request: web.Request) -> web.StreamResponse: ) as exc: raise web.HTTPConflict(reason=f"{exc}") from exc + except PaymentServiceUnavailableError as exc: + raise web.HTTPServiceUnavailable(reason=f"{exc}") from exc + except WalletAccessForbiddenError as exc: raise web.HTTPForbidden(reason=f"{exc}") from exc except ProductPriceNotDefinedError as exc: - raise web.HTTPConflict(reason=MSG_PRICE_NOT_DEFINED_ERROR) + raise web.HTTPConflict(reason=MSG_PRICE_NOT_DEFINED_ERROR) from exc return wrapper