Skip to content

Commit ba59e47

Browse files
committed
feat(core): add idempotent flag on RPC API (#10)
The goal of this change is to continue retrying calls in the event of network loss, while also allowing this feature to be disabled for calls that are not idempotent and require more intelligence on the caller side. Signed-off-by: Ronan Abhamon <ronan.abhamon@vates.tech>
1 parent b6ac259 commit ba59e47

5 files changed

Lines changed: 128 additions & 14 deletions

File tree

‎src/xcp_storage/rpc/client.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@ def call_api(
7474
*args: P.args,
7575
**kwargs: P.kwargs
7676
) -> JsonValueT:
77+
"""
78+
Call the RPC API `method`. The call is retried if the connection is lost, unless the method is
79+
declared with `idempotent=False` (see `JsonRpcDispatcher.method`).
80+
See `JsonRpcClient.call_with_timeout` for more details.
81+
"""
82+
7783
return self.call_api_with_timeout(self._client_timeout, method, *args, **kwargs)
7884

7985
def call_api_with_timeout(
@@ -87,6 +93,7 @@ def call_api_with_timeout(
8793
method_name = cast(Any, method)._rpc_name # noqa: SLF001
8894
except AttributeError:
8995
raise JsonRpcRequestError("Method is not marked as RPC.") from None
96+
idempotent = getattr(method, "_rpc_idempotent", True)
9097

9198
if args and kwargs:
9299
raise JsonRpcRequestError("Positional and named arguments cannot be mixed.")
@@ -99,4 +106,4 @@ def call_api_with_timeout(
99106
else:
100107
params = None
101108

102-
return cast(JsonValueT, self.call_with_timeout(timeout, method_name, params))
109+
return cast(JsonValueT, self.call_with_timeout(timeout, method_name, params, retry_on_disconnect=idempotent))

‎src/xcp_storage/utils/json/rpc/__init__.py‎

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
Final,
3131
List,
3232
Optional,
33+
overload,
3334
override,
3435
ParamSpec,
3536
TypeVar,
@@ -516,14 +517,42 @@ def call_method(self, method: str, *args: Any, **kwargs: Any) -> Any: # noqa: AN
516517

517518
return JsonRpcCallResult(result=result)
518519

520+
@overload
519521
def method(self, func: Callable[P, T]) -> Callable[P, T]:
520-
name = func.__name__
521-
if self._use_module_name:
522-
name = PurePath(inspect.getfile(func)).stem + "." + name
523-
self._name_to_method[name] = func
522+
...
524523

525-
cast(Any, func)._rpc_name = name # noqa: SLF001
526-
return func
524+
@overload
525+
def method(self, *, idempotent: bool = True) -> Callable[[Callable[P, T]], Callable[P, T]]:
526+
...
527+
528+
def method(
529+
self,
530+
func: Optional[Callable[P, T]] = None,
531+
*,
532+
idempotent: bool = True
533+
) -> Union[Callable[P, T], Callable[[Callable[P, T]], Callable[P, T]]]:
534+
"""
535+
Register a function as RPC method.
536+
537+
`idempotent=False` declares a method that must not be executed twice: a client calling it
538+
through the API never sends the request again after a lost connection
539+
(see `JsonRpcClient.call_with_timeout`).
540+
"""
541+
542+
def register(target: Callable[P, T]) -> Callable[P, T]:
543+
name = target.__name__
544+
if self._use_module_name:
545+
name = PurePath(inspect.getfile(target)).stem + "." + name
546+
self._name_to_method[name] = target
547+
548+
target_info = cast(Any, target)
549+
target_info._rpc_name = name # noqa: SLF001
550+
target_info._rpc_idempotent = idempotent # noqa: SLF001
551+
return target
552+
553+
if func is None:
554+
return register
555+
return register(func)
527556

528557
# ------------------------------------------------------------------------------
529558
# Request processor.

‎src/xcp_storage/utils/json/rpc/client.py‎

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,23 +117,53 @@ def disconnect(self) -> None:
117117
def call(
118118
self,
119119
method: str,
120-
params: Union[JsonList, JsonDict, None] = None
120+
params: Union[JsonList, JsonDict, None] = None,
121+
*,
122+
retry_on_disconnect: bool = True
121123
) -> JsonValue:
122-
return self.call_with_timeout(self._client_timeout, method, params)
124+
"""
125+
Call `method` with the client timeout. See `call_with_timeout` for the retry semantics.
126+
"""
127+
128+
return self.call_with_timeout(self._client_timeout, method, params, retry_on_disconnect=retry_on_disconnect)
123129

124130
def call_with_timeout(
125131
self,
126132
timeout: float,
127133
method: str,
128-
params: Union[JsonList, JsonDict, None] = None
134+
params: Union[JsonList, JsonDict, None] = None,
135+
*,
136+
retry_on_disconnect: bool = True
129137
) -> JsonValue:
138+
"""
139+
Call `method` and return its result.
140+
141+
If the connection is lost (`SocketDisconnectedError`), the client reconnects and sends the
142+
same request again, until `timeout` is elapsed. The delivery is therefore "at least once":
143+
the failure can occur while waiting for the response, after the server has already executed
144+
the request. RPC methods exposed through this client MUST be idempotent, or tolerate being
145+
executed twice.
146+
147+
For the rare methods that must not be executed twice, use `retry_on_disconnect=False` (the
148+
API client does it for methods declared with `idempotent=False`): the request is sent at most
149+
once and `SocketDisconnectedError` is raised on a lost connection.
150+
It is then up to the caller to determine if the method has been executed. Note that the
151+
client still waits up to `timeout` for the initial connection to the server: nothing has
152+
been sent at this point.
153+
154+
Other errors are not retried: a timeout (`SocketTimeoutError`) or an error response from
155+
the server is raised as is.
156+
"""
157+
130158
remaining_time = timeout
131159
start_time = time.monotonic()
132160

133161
while True:
134162
try:
135163
return self._call(remaining_time, method, params)
136164
except SocketDisconnectedError: # noqa: PERF203
165+
if not retry_on_disconnect:
166+
raise
137167
remaining_time = timeout - (time.monotonic() - start_time)
138168
if remaining_time <= 0:
139169
raise

‎tests/rpc/test_rpc_client_server.py‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,11 @@
2222
from tests.rpc.conftest import CLIENT_TIMEOUT, RpcClientFactory
2323
from xcp_storage.network.protocol import Protocol, ProtocolError
2424
from xcp_storage.network.protocol.xcp import XcpProtocol
25+
from xcp_storage.network.socket import SocketDisconnectedError
2526
from xcp_storage.network.tcp_client import TcpClientError
2627
from xcp_storage.rpc.client import RpcApiClient
2728
from xcp_storage.rpc.server import RpcApiServer
29+
from xcp_storage.utils.json import JsonDict
2830
from xcp_storage.utils.json.rpc import JsonRpcRequestError
2931

3032
from xcp_storage.typing import (
@@ -65,7 +67,10 @@ def test_connect_with_untrusted_certificate(self, rpc_server: RpcApiServer) -> N
6567

6668
@over_plain_and_tls
6769
class TestRpcClientServer:
68-
def test_retry_call_on_socket_disconnect(self, rpc_client_factory: RpcClientFactory) -> None:
70+
@pytest.mark.parametrize("retry_on_disconnect", [True, False])
71+
def test_retry_call_on_socket_disconnect(
72+
self, rpc_client_factory: RpcClientFactory, *, retry_on_disconnect: bool
73+
) -> None:
6974
rpc_client = rpc_client_factory()
7075

7176
rpc_client.connect()
@@ -76,8 +81,18 @@ def test_retry_call_on_socket_disconnect(self, rpc_client_factory: RpcClientFact
7681
tpc_socket.close()
7782

7883
message = "Bonjour !"
79-
response = rpc_client.call("echo.echo", params={"message": message})
80-
assert response == message
84+
params: JsonDict = {"message": message}
85+
with patch.object(rpc_client, "_call", wraps=rpc_client._call) as call_spy: # noqa: SLF001
86+
if retry_on_disconnect:
87+
assert rpc_client.call("echo.echo", params=params) == message
88+
else:
89+
with pytest.raises(SocketDisconnectedError):
90+
rpc_client.call("echo.echo", params=params, retry_on_disconnect=False)
91+
92+
assert call_spy.call_count == (2 if retry_on_disconnect else 1)
93+
assert rpc_client.connected == retry_on_disconnect
94+
95+
assert rpc_client.call("echo.echo", params=params) == message
8196
assert rpc_client.connected
8297

8398
def test_rpc_packet_last_sequences(self, rpc_client_factory: RpcClientFactory) -> None:

‎tests/utils/test_json_rpc.py‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,11 @@
2323
JsonRpcResponse,
2424
)
2525

26-
from xcp_storage.typing import Union
26+
from xcp_storage.typing import (
27+
Any,
28+
cast,
29+
Union,
30+
)
2731

2832
# ==============================================================================
2933

@@ -73,6 +77,35 @@ def assert_response_error(
7377

7478
# ------------------------------------------------------------------------------
7579

80+
class TestJsonRpcDispatcherMethod:
81+
def test_method_is_idempotent_by_default(self) -> None:
82+
dispatcher = JsonRpcDispatcher()
83+
84+
@dispatcher.method
85+
def add(a: int, b: int) -> int:
86+
return a + b
87+
88+
assert add(1, 2) == 3
89+
90+
method_info = cast(Any, add)
91+
assert method_info._rpc_name == "add" # noqa: SLF001
92+
assert method_info._rpc_idempotent # noqa: SLF001
93+
94+
def test_method_with_arguments(self) -> None:
95+
dispatcher = JsonRpcDispatcher()
96+
97+
@dispatcher.method(idempotent=False)
98+
def add(a: int, b: int) -> int:
99+
return a + b
100+
101+
assert add(1, 2) == 3
102+
103+
method_info = cast(Any, add)
104+
assert method_info._rpc_name == "add" # noqa: SLF001
105+
assert not method_info._rpc_idempotent # noqa: SLF001
106+
107+
# ------------------------------------------------------------------------------
108+
76109
class TestJsonRpcRequest:
77110
def test_positional_params(self) -> None:
78111
dispatcher = JsonRpcDispatcher()

0 commit comments

Comments
 (0)