use std::any::type_name;
use std::fmt;
use std::time::Duration;
use serde::de::DeserializeOwned;
use tracing::{instrument, trace};
use crate::common::ask::{AskError, DEFAULT_ASK_TIMEOUT};
use crate::common::ipc::client::IpcClient;
use crate::common::ipc::types::{IpcEnvelope, IpcError, IpcResponse, NO_REPLY_MESSAGE};
use crate::traits::RemoteRequest;
const TIMEOUT_CODE: &str = "TIMEOUT";
const IO_ERROR_CODE: &str = "IO_ERROR";
#[derive(Clone, Copy)]
pub struct RemoteActorRef<'client> {
client: &'client IpcClient,
target: &'client str,
}
impl fmt::Debug for RemoteActorRef<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RemoteActorRef")
.field("target", &self.target)
.finish_non_exhaustive()
}
}
impl<'client> RemoteActorRef<'client> {
pub(crate) const fn new(client: &'client IpcClient, target: &'client str) -> Self {
Self { client, target }
}
#[must_use]
pub const fn target(&self) -> &str {
self.target
}
pub async fn ask<R>(&self, request: R) -> Result<R::Response, AskError>
where
R: RemoteRequest,
R::Response: DeserializeOwned,
{
self.ask_with_timeout(request, DEFAULT_ASK_TIMEOUT).await
}
#[instrument(
skip(self, request),
fields(actor = self.target, message_type = R::MESSAGE_TYPE)
)]
pub async fn ask_with_timeout<R>(
&self,
request: R,
timeout: Duration,
) -> Result<R::Response, AskError>
where
R: RemoteRequest,
R::Response: DeserializeOwned,
{
let payload = serde_json::to_value(&request).map_err(|_| AskError::Undeliverable)?;
let envelope = IpcEnvelope::new_request_with_timeout(
self.target,
R::MESSAGE_TYPE,
payload,
timeout_millis(timeout),
);
trace!(actor = self.target, "Asking remote actor and awaiting its reply");
match self.client.request_with_timeout(envelope, timeout).await {
Ok(response) => classify_remote_response::<R::Response>(response, timeout),
Err(error) => Err(classify_ipc_error(&error, timeout)),
}
}
}
fn timeout_millis(timeout: Duration) -> u64 {
u64::try_from(timeout.as_millis()).unwrap_or(u64::MAX)
}
pub fn classify_remote_response<R>(
response: IpcResponse,
timeout: Duration,
) -> Result<R, AskError>
where
R: DeserializeOwned,
{
if !response.success {
return Err(classify_failure(
response.error_code.as_deref(),
response.error.as_deref(),
timeout,
));
}
let Some(payload) = response.payload else {
return Err(AskError::NoReply);
};
R::deserialize(&payload).map_err(|_| AskError::UnexpectedReply {
expected: type_name::<R>(),
received: payload.to_string(),
})
}
fn classify_failure(code: Option<&str>, detail: Option<&str>, timeout: Duration) -> AskError {
let detail = detail.unwrap_or("the peer reported a failure without describing it");
match code {
Some(TIMEOUT_CODE) => AskError::TimedOut { after: timeout },
Some(IO_ERROR_CODE) if detail.contains(NO_REPLY_MESSAGE) => AskError::NoReply,
Some(IO_ERROR_CODE) => AskError::TransportFailed {
detail: detail.to_owned(),
},
other => AskError::PeerRejected {
code: other.map(ToOwned::to_owned),
detail: detail.to_owned(),
},
}
}
pub fn classify_ipc_error(error: &IpcError, timeout: Duration) -> AskError {
match error {
IpcError::Timeout => AskError::TimedOut { after: timeout },
IpcError::ActorNotFound(_)
| IpcError::UnknownMessageType(_)
| IpcError::TargetBusy
| IpcError::RateLimited { .. }
| IpcError::ShuttingDown
| IpcError::ConnectionLimitReached { .. }
| IpcError::UnsupportedProtocolVersion { .. } => AskError::PeerRejected {
code: None,
detail: error.to_string(),
},
IpcError::ConnectionClosed | IpcError::IoError(_) | IpcError::ProtocolError(_) => {
AskError::TransportFailed {
detail: error.to_string(),
}
}
IpcError::SerializationError(_) => AskError::Undeliverable,
}
}
#[cfg(test)]
mod tests {
use serde::{Deserialize, Serialize};
use super::*;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct Count {
value: usize,
}
const TEST_TIMEOUT: Duration = Duration::from_secs(5);
fn ask(response: IpcResponse) -> Result<Count, AskError> {
classify_remote_response::<Count>(response, TEST_TIMEOUT)
}
#[test]
fn a_reply_of_the_declared_type_is_returned_to_the_caller() {
let response = IpcResponse::success("c1", Some(serde_json::json!({ "value": 7 })));
assert_eq!(ask(response), Ok(Count { value: 7 }));
}
#[test]
fn a_success_carrying_no_payload_is_reported_as_no_reply() {
let response = IpcResponse::success("c1", None);
assert_eq!(ask(response), Err(AskError::NoReply));
}
#[test]
fn a_reply_of_another_shape_is_reported_rather_than_discarded() {
let response = IpcResponse::success("c1", Some(serde_json::json!({ "wrong": true })));
match ask(response) {
Err(AskError::UnexpectedReply { expected, received }) => {
assert!(
expected.ends_with("Count"),
"the expected type should name the declared reply, got `{expected}`"
);
assert!(
received.contains("wrong"),
"the raw payload should identify what arrived, got `{received}`"
);
}
other => panic!("expected UnexpectedReply, got {other:?}"),
}
}
#[test]
fn an_unregistered_reply_type_names_itself_in_the_error() {
let response = IpcResponse::success(
"c1",
Some(serde_json::json!({
"_ipc_fallback": true,
"type": "Count",
"debug": "Count { value: 7 }",
})),
);
match ask(response) {
Err(AskError::UnexpectedReply { received, .. }) => {
assert!(
received.contains("_ipc_fallback"),
"the fallback marker should survive, got `{received}`"
);
}
other => panic!("expected UnexpectedReply, got {other:?}"),
}
}
#[test]
fn a_peer_side_deadline_is_a_timeout_not_a_refusal() {
let response =
IpcResponse::error_with_message("c1", "TIMEOUT", "Request timed out after 5000 ms");
assert_eq!(ask(response), Err(AskError::TimedOut { after: TEST_TIMEOUT }));
}
#[test]
fn a_silent_handler_is_no_reply_rather_than_a_transport_failure() {
let response = IpcResponse::error(
"c1",
&IpcError::IoError(NO_REPLY_MESSAGE.to_owned()),
);
assert_eq!(ask(response), Err(AskError::NoReply));
}
#[test]
fn a_genuine_io_failure_remains_a_transport_failure() {
let response =
IpcResponse::error("c1", &IpcError::IoError("broken pipe".to_owned()));
match ask(response) {
Err(AskError::TransportFailed { detail }) => {
assert!(
detail.contains("broken pipe"),
"the transport's own words should survive, got `{detail}`"
);
}
other => panic!("expected TransportFailed, got {other:?}"),
}
}
#[test]
fn a_refusal_before_dispatch_keeps_the_peers_code() {
let response =
IpcResponse::error("c1", &IpcError::ActorNotFound("counter".to_owned()));
match ask(response) {
Err(AskError::PeerRejected { code, detail }) => {
assert_eq!(code.as_deref(), Some("ACTOR_NOT_FOUND"));
assert!(
detail.contains("counter"),
"the detail should name the actor asked for, got `{detail}`"
);
}
other => panic!("expected PeerRejected, got {other:?}"),
}
}
#[test]
fn a_failure_the_peer_did_not_describe_is_still_terminal() {
let response = IpcResponse {
correlation_id: "c1".to_owned(),
success: false,
error: None,
error_code: None,
payload: None,
};
match ask(response) {
Err(AskError::PeerRejected { code, .. }) => assert_eq!(code, None),
other => panic!("expected PeerRejected, got {other:?}"),
}
}
#[test]
fn a_client_side_deadline_maps_to_the_timeout_error() {
assert_eq!(
classify_ipc_error(&IpcError::Timeout, TEST_TIMEOUT),
AskError::TimedOut { after: TEST_TIMEOUT }
);
}
#[test]
fn a_broken_connection_never_claims_the_request_was_not_processed() {
for error in [
IpcError::ConnectionClosed,
IpcError::IoError("reset".to_owned()),
IpcError::ProtocolError("short frame".to_owned()),
] {
assert!(
matches!(
classify_ipc_error(&error, TEST_TIMEOUT),
AskError::TransportFailed { .. }
),
"{error:?} leaves delivery uncertain and must say so"
);
}
}
#[test]
fn refusals_that_never_reached_an_actor_are_reported_as_such() {
for error in [
IpcError::ActorNotFound("nobody".to_owned()),
IpcError::UnknownMessageType("Nope".to_owned()),
IpcError::TargetBusy,
IpcError::RateLimited { retry_after_ms: 10 },
IpcError::ShuttingDown,
IpcError::ConnectionLimitReached { limit: 4 },
] {
assert!(
matches!(
classify_ipc_error(&error, TEST_TIMEOUT),
AskError::PeerRejected { .. }
),
"{error:?} ran no handler and must say so"
);
}
}
#[test]
fn a_request_that_cannot_be_serialized_is_undeliverable() {
assert_eq!(
classify_ipc_error(&IpcError::SerializationError("nan".to_owned()), TEST_TIMEOUT),
AskError::Undeliverable
);
}
#[test]
fn an_unrepresentable_deadline_saturates_rather_than_wrapping() {
assert_eq!(timeout_millis(Duration::from_secs(5)), 5_000);
assert_eq!(timeout_millis(Duration::MAX), u64::MAX);
}
#[test]
fn every_new_variant_describes_itself() {
let variants = [
AskError::PeerRejected {
code: Some("ACTOR_NOT_FOUND".to_owned()),
detail: "Actor not found: counter".to_owned(),
},
AskError::PeerRejected {
code: None,
detail: "unexplained".to_owned(),
},
AskError::TransportFailed {
detail: "broken pipe".to_owned(),
},
];
for variant in variants {
assert!(
!variant.to_string().is_empty(),
"{variant:?} must render a message"
);
}
}
}