Skip to content

Commit 535f59d

Browse files
authored
♻️ servicelib.fastapi tools and rabbitmq.rpc errors interface (#5157)
1 parent 09dd712 commit 535f59d

33 files changed

+547
-280
lines changed

.env-devel

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
# local development
12
#
23
# - Keep it alfphabetical order and grouped by prefix [see vscode cmd: Sort Lines Ascending]
34
# - To expose:
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
from pydantic.errors import PydanticErrorMixin
2+
3+
4+
class _BaseRpcApiError(PydanticErrorMixin, ValueError):
5+
@classmethod
6+
def get_full_class_name(cls) -> str:
7+
# Can be used as unique code identifier
8+
return f"{cls.__module__}.{cls.__name__}"
9+
10+
11+
#
12+
# service-wide errors
13+
#
14+
15+
16+
class PaymentServiceUnavailableError(_BaseRpcApiError):
17+
msg_template = "Payments are currently unavailable: {human_readable_detail}"
18+
19+
20+
#
21+
# payment transactions errors
22+
#
23+
24+
25+
class PaymentsError(_BaseRpcApiError):
26+
msg_template = "Error in payment transaction '{payment_id}'"
27+
28+
29+
class PaymentNotFoundError(PaymentsError):
30+
msg_template = "Payment transaction '{payment_id}' was not found"
31+
32+
33+
class PaymentAlreadyExistsError(PaymentsError):
34+
msg_template = "Payment transaction '{payment_id}' was already initialized"
35+
36+
37+
class PaymentAlreadyAckedError(PaymentsError):
38+
msg_template = "Payment transaction '{payment_id}' cannot be changes since it was already closed."
39+
40+
41+
#
42+
# payment-methods errors
43+
#
44+
45+
46+
class PaymentsMethodsError(_BaseRpcApiError):
47+
...
48+
49+
50+
class PaymentMethodNotFoundError(PaymentsMethodsError):
51+
msg_template = "The specified payment method '{payment_method_id}' does not exist"
52+
53+
54+
class PaymentMethodAlreadyAckedError(PaymentsMethodsError):
55+
msg_template = (
56+
"Cannot create payment-method '{payment_method_id}' since it was already closed"
57+
)
58+
59+
60+
class PaymentMethodUniqueViolationError(PaymentsMethodsError):
61+
msg_template = "Payment method '{payment_method_id}' aready exists"
62+
63+
64+
class InvalidPaymentMethodError(PaymentsMethodsError):
65+
msg_template = "Invalid payment method '{payment_method_id}'"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import logging
2+
3+
from fastapi import FastAPI
4+
5+
_logger = logging.getLogger(__name__)
6+
7+
8+
class SingletonInAppStateMixin:
9+
"""
10+
Mixin to get, set and delete an instance of 'self' from/to app.state
11+
"""
12+
13+
app_state_name: str # Name used in app.state.$(app_state_name)
14+
frozen: bool = True # Will raise if set multiple times
15+
16+
@classmethod
17+
def get_from_app_state(cls, app: FastAPI):
18+
return getattr(app.state, cls.app_state_name)
19+
20+
def set_to_app_state(self, app: FastAPI):
21+
if (exists := getattr(app.state, self.app_state_name, None)) and self.frozen:
22+
msg = f"An instance of {type(self)} already in app.state.{self.app_state_name}={exists}"
23+
raise ValueError(msg)
24+
25+
setattr(app.state, self.app_state_name, self)
26+
return self.get_from_app_state(app)
27+
28+
@classmethod
29+
def pop_from_app_state(cls, app: FastAPI):
30+
"""
31+
Raises:
32+
AttributeError: if instance is not in app.state
33+
"""
34+
old = getattr(app.state, cls.app_state_name)
35+
delattr(app.state, cls.app_state_name)
36+
return old

packages/service-library/src/servicelib/fastapi/http_client.py

-70
Original file line numberDiff line numberDiff line change
@@ -58,73 +58,3 @@ async def check_liveness(self) -> LivenessResult:
5858
return IsResponsive(elapsed=response.elapsed)
5959
except httpx.RequestError as err:
6060
return IsNonResponsive(reason=f"{err}")
61-
62-
63-
class AppStateMixin:
64-
"""
65-
Mixin to get, set and delete an instance of 'self' from/to app.state
66-
"""
67-
68-
app_state_name: str # Name used in app.state.$(app_state_name)
69-
frozen: bool = True # Will raise if set multiple times
70-
71-
@classmethod
72-
def get_from_app_state(cls, app: FastAPI):
73-
return getattr(app.state, cls.app_state_name)
74-
75-
def set_to_app_state(self, app: FastAPI):
76-
if (exists := getattr(app.state, self.app_state_name, None)) and self.frozen:
77-
msg = f"An instance of {type(self)} already in app.state.{self.app_state_name}={exists}"
78-
raise ValueError(msg)
79-
80-
setattr(app.state, self.app_state_name, self)
81-
return self.get_from_app_state(app)
82-
83-
@classmethod
84-
def pop_from_app_state(cls, app: FastAPI):
85-
"""
86-
Raises:
87-
AttributeError: if instance is not in app.state
88-
"""
89-
old = getattr(app.state, cls.app_state_name)
90-
delattr(app.state, cls.app_state_name)
91-
return old
92-
93-
94-
def to_curl_command(request: httpx.Request, *, use_short_options: bool = True) -> str:
95-
"""Composes a curl command from a given request
96-
97-
Can be used to reproduce a request in a separate terminal (e.g. debugging)
98-
"""
99-
# Adapted from https://github.com/marcuxyz/curlify2/blob/master/curlify2/curlify.py
100-
method = request.method
101-
url = request.url
102-
103-
# https://curl.se/docs/manpage.html#-X
104-
# -X, --request {method}
105-
_x = "-X" if use_short_options else "--request"
106-
request_option = f"{_x} {method}"
107-
108-
# https://curl.se/docs/manpage.html#-d
109-
# -d, --data <data> HTTP POST data
110-
data_option = ""
111-
if body := request.read().decode():
112-
_d = "-d" if use_short_options else "--data"
113-
data_option = f"{_d} '{body}'"
114-
115-
# https://curl.se/docs/manpage.html#-H
116-
# H, --header <header/@file> Pass custom header(s) to server
117-
118-
headers_option = ""
119-
headers = []
120-
for key, value in request.headers.items():
121-
if "secret" in key.lower() or "pass" in key.lower():
122-
headers.append(f'"{key}: *****"')
123-
else:
124-
headers.append(f'"{key}: {value}"')
125-
126-
if headers:
127-
_h = "-H" if use_short_options else "--header"
128-
headers_option = f"{_h} {f' {_h} '.join(headers)}"
129-
130-
return f"curl {request_option} {headers_option} {data_option} {url}"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import httpx
2+
3+
4+
def _is_secret(k: str) -> bool:
5+
return "secret" in k.lower() or "pass" in k.lower()
6+
7+
8+
def _get_headers_safely(request: httpx.Request) -> dict[str, str]:
9+
return {k: "*" * 5 if _is_secret(k) else v for k, v in request.headers.items()}
10+
11+
12+
def to_httpx_command(
13+
request: httpx.Request, *, use_short_options: bool = True, multiline: bool = False
14+
) -> str:
15+
"""Command with httpx CLI
16+
17+
$ httpx --help
18+
19+
NOTE: Particularly handy as an alternative to curl (e.g. when docker exec in osparc containers)
20+
SEE https://www.python-httpx.org/
21+
"""
22+
cmd = [
23+
"httpx",
24+
]
25+
26+
# -m, --method METHOD
27+
cmd.append(f'{"-m" if use_short_options else "--method"} {request.method}')
28+
29+
# -c, --content TEXT Byte content to include in the request body.
30+
if content := request.read().decode():
31+
cmd.append(f'{"-c" if use_short_options else "--content"} \'{content}\'')
32+
33+
# -h, --headers <NAME VALUE> ... Include additional HTTP headers in the request.
34+
if headers := _get_headers_safely(request):
35+
cmd.extend(
36+
[
37+
f'{"-h" if use_short_options else "--headers"} "{name}" "{value}"'
38+
for name, value in headers.items()
39+
]
40+
)
41+
42+
cmd.append(f"{request.url}")
43+
separator = " \\\n" if multiline else " "
44+
return separator.join(cmd)
45+
46+
47+
def to_curl_command(
48+
request: httpx.Request, *, use_short_options: bool = True, multiline: bool = False
49+
) -> str:
50+
"""Composes a curl command from a given request
51+
52+
$ curl --help
53+
54+
NOTE: Handy reproduce a request in a separate terminal (e.g. debugging)
55+
"""
56+
# Adapted from https://github.com/marcuxyz/curlify2/blob/master/curlify2/curlify.py
57+
cmd = [
58+
"curl",
59+
]
60+
61+
# https://curl.se/docs/manpage.html#-X
62+
# -X, --request {method}
63+
cmd.append(f'{"-X" if use_short_options else "--request"} {request.method}')
64+
65+
# https://curl.se/docs/manpage.html#-H
66+
# H, --header <header/@file> Pass custom header(s) to server
67+
if headers := _get_headers_safely(request):
68+
cmd.extend(
69+
[
70+
f'{"-H" if use_short_options else "--header"} "{k}: {v}"'
71+
for k, v in headers.items()
72+
]
73+
)
74+
75+
# https://curl.se/docs/manpage.html#-d
76+
# -d, --data <data> HTTP POST data
77+
if body := request.read().decode():
78+
_d = "-d" if use_short_options else "--data"
79+
cmd.append(f"{_d} '{body}'")
80+
81+
cmd.append(f"{request.url}")
82+
83+
separator = " \\\n" if multiline else " "
84+
return separator.join(cmd)

packages/service-library/src/servicelib/rabbitmq/_client_rpc.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from ..logging_utils import log_context
1414
from ._client_base import RabbitMQClientBase
15+
from ._constants import RPC_REQUEST_DEFAULT_TIMEOUT_S
1516
from ._errors import RemoteMethodNotRegisteredError, RPCNotInitializedError
1617
from ._models import RPCNamespacedMethodName
1718
from ._rpc_router import RPCRouter
@@ -65,7 +66,7 @@ async def request(
6566
namespace: RPCNamespace,
6667
method_name: RPCMethodName,
6768
*,
68-
timeout_s: PositiveInt | None = 5,
69+
timeout_s: PositiveInt | None = RPC_REQUEST_DEFAULT_TIMEOUT_S,
6970
**kwargs,
7071
) -> Any:
7172
"""
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
from typing import Final
22

3+
from pydantic import PositiveInt
4+
35
BIND_TO_ALL_TOPICS: Final[str] = "#"
6+
RPC_REQUEST_DEFAULT_TIMEOUT_S: Final[PositiveInt] = PositiveInt(5)
47
RPC_REMOTE_METHOD_TIMEOUT_S: Final[int] = 30

packages/service-library/src/servicelib/rabbitmq/_rpc_router.py

+36-11
Original file line numberDiff line numberDiff line change
@@ -6,48 +6,73 @@
66
from typing import Any, TypeVar
77

88
from models_library.rabbitmq_basic_types import RPCMethodName
9-
from pydantic import SecretStr
109

1110
from ..logging_utils import log_context
1211
from ._errors import RPCServerError
1312

1413
DecoratedCallable = TypeVar("DecoratedCallable", bound=Callable[..., Any])
1514

15+
# NOTE: this is equivalent to http access logs
1616
_logger = logging.getLogger("rpc.access")
1717

18-
_RPC_CUSTOM_ENCODER: dict[Any, Callable[[Any], Any]] = {
19-
SecretStr: SecretStr.get_secret_value
20-
}
18+
19+
def _create_func_msg(func, args: tuple[Any, ...], kwargs: dict[str, Any]) -> str:
20+
msg = f"{func.__name__}("
21+
22+
if args_msg := ", ".join(map(str, args)):
23+
msg += args_msg
24+
25+
if kwargs_msg := ", ".join({f"{name}={value}" for name, value in kwargs.items()}):
26+
if args:
27+
msg += ", "
28+
msg += kwargs_msg
29+
30+
return f"{msg})"
2131

2232

2333
@dataclass
2434
class RPCRouter:
2535
routes: dict[RPCMethodName, Callable] = field(default_factory=dict)
2636

27-
def expose(self) -> Callable[[DecoratedCallable], DecoratedCallable]:
28-
def decorator(func: DecoratedCallable) -> DecoratedCallable:
37+
def expose(
38+
self,
39+
*,
40+
reraise_if_error_type: tuple[type[Exception], ...] | None = None,
41+
) -> Callable[[DecoratedCallable], DecoratedCallable]:
42+
def _decorator(func: DecoratedCallable) -> DecoratedCallable:
2943
@functools.wraps(func)
30-
async def wrapper(*args, **kwargs):
44+
async def _wrapper(*args, **kwargs):
45+
3146
with log_context(
47+
# NOTE: this is intentionally analogous to the http access log traces.
48+
# To change log-level use getLogger("rpc.access").set_level(...)
3249
_logger,
3350
logging.INFO,
34-
msg=f"calling {func.__name__} with {args}, {kwargs}",
51+
msg=f"RPC call {_create_func_msg(func, args, kwargs)}",
52+
log_duration=True,
3553
):
3654
try:
3755
return await func(*args, **kwargs)
56+
3857
except asyncio.CancelledError:
3958
_logger.debug("call was cancelled")
4059
raise
60+
4161
except Exception as exc: # pylint: disable=broad-except
62+
if reraise_if_error_type and isinstance(
63+
exc, reraise_if_error_type
64+
):
65+
raise
66+
4267
_logger.exception("Unhandled exception:")
4368
# NOTE: we do not return internal exceptions over RPC
4469
raise RPCServerError(
4570
method_name=func.__name__,
46-
exc_type=f"{type(exc)}",
71+
exc_type=f"{exc.__class__.__module__}.{exc.__class__.__name__}",
4772
msg=f"{exc}",
4873
) from None
4974

50-
self.routes[RPCMethodName(func.__name__)] = wrapper
75+
self.routes[RPCMethodName(func.__name__)] = _wrapper
5176
return func
5277

53-
return decorator
78+
return _decorator

0 commit comments

Comments
 (0)