use devicerail_client::ClientError;
use devicerail_client::protocol::RpcError;
use pointlock_ir::ErrorClass;
use pointlock_provider_kit::{ProviderError, RetryableSource};
use crate::convert::error_info_from_wire;
pub const DRIVER_ERROR_RPC_CODE: i32 = -32000;
pub fn classify_wire_code(code: &str, retryable: bool) -> (ErrorClass, RetryableSource) {
match code {
"element_not_found" | "element_ambiguous" => {
(ErrorClass::ActionFailedFinal, RetryableSource::Classifier)
}
"element_stale" | "ui_context_changed" => {
(ErrorClass::TargetStale, RetryableSource::Classifier)
}
"device_unavailable" => (ErrorClass::ActionFailedRetryable, RetryableSource::Daemon),
"session_degraded" => (ErrorClass::SessionDegraded, RetryableSource::Classifier),
"invalid_arguments" => (
ErrorClass::BindArgumentsInvalid,
RetryableSource::Classifier,
),
"action_timeout" | "action_timed_out" | "request_timed_out" => {
(ErrorClass::ActionTimedOut, RetryableSource::Classifier)
}
"action_cancelled" | "request_cancelled" => {
(ErrorClass::ActionCancelled, RetryableSource::Classifier)
}
_ if retryable => (ErrorClass::ActionFailedRetryable, RetryableSource::Daemon),
_ => (ErrorClass::ActionFailedFinal, RetryableSource::Daemon),
}
}
pub fn classify_remote_rpc(error: &RpcError) -> (ErrorClass, RetryableSource) {
match error.data.code.as_str() {
"feature_not_negotiated"
| "required_feature_unsupported"
| "semantic_snapshot_dependency_unsatisfied" => {
(ErrorClass::CapabilityDrift, RetryableSource::Classifier)
}
"invalid_params" => (
ErrorClass::BindArgumentsInvalid,
RetryableSource::Classifier,
),
code => classify_wire_code(code, error.data.retryable),
}
}
pub fn provider_error_from_client(error: ClientError, context: &str) -> ProviderError {
let message = format!("{context}: {error}");
match error {
ClientError::RemoteRpc { error, .. } => {
let (class, source) = classify_remote_rpc(&error);
ProviderError::new(class, message, source)
.with_wire(error_info_from_wire(&error.data))
.with_client_code("remote_rpc_error")
}
ClientError::FeatureNotNegotiated { .. } => ProviderError::new(
ErrorClass::CapabilityDrift,
message,
RetryableSource::Classifier,
)
.with_client_code("feature_not_negotiated"),
ClientError::Transport(_) | ClientError::Closed => ProviderError::new(
ErrorClass::TransportLost,
message,
RetryableSource::Classifier,
)
.with_client_code("transport_closed"),
ClientError::Framing(_) => ProviderError::new(
ErrorClass::TransportLost,
message,
RetryableSource::Classifier,
)
.with_client_code("ndjson_frame_error"),
ClientError::ProtocolViolation(_) => ProviderError::new(
ErrorClass::TransportLost,
message,
RetryableSource::Classifier,
)
.with_client_code("protocol_violation"),
ClientError::Serialization(_) => ProviderError::new(
ErrorClass::TransportLost,
message,
RetryableSource::Classifier,
)
.with_client_code("serialization"),
ClientError::PendingRequestLimit(_)
| ClientError::AbandonedRequestLimit(_)
| ClientError::WriteQueueFull { .. } => ProviderError::new(
ErrorClass::TransportLost,
message,
RetryableSource::Classifier,
)
.with_client_code("write_queue_overflow"),
ClientError::WriteFrameTooLarge { .. } => ProviderError::new(
ErrorClass::TransportLost,
message,
RetryableSource::Classifier,
)
.with_client_code("write_frame_too_large"),
ClientError::HandshakeState(state @ ("failed" | "closed" | "closing")) => {
ProviderError::new(
ErrorClass::TransportLost,
message,
RetryableSource::Classifier,
)
.with_client_code(format!("handshake_state:{state}"))
}
ClientError::HandshakeState(_) => ProviderError::new(
ErrorClass::ActionFailedFinal,
message,
RetryableSource::Classifier,
)
.with_client_code("handshake_state"),
ClientError::RuntimeUnavailable | ClientError::Internal(_) => ProviderError::new(
ErrorClass::ActionFailedFinal,
message,
RetryableSource::Classifier,
)
.with_client_code("internal"),
}
}
pub fn execute_terminal_from_rpc(error: &RpcError) -> Option<pointlock_ir::ActionOutcome> {
use pointlock_ir::ActionOutcome;
let info = error_info_from_wire(&error.data);
match error.data.code.as_str() {
"request_cancelled" | "action_cancelled" => Some(ActionOutcome::Cancelled { error: info }),
"request_timed_out" | "action_timed_out" | "action_timeout" => {
Some(ActionOutcome::TimedOut { error: info })
}
"semantic_channel_unavailable" => None,
_ if error.code == DRIVER_ERROR_RPC_CODE => Some(ActionOutcome::Failed { error: info }),
_ => None,
}
}
pub(crate) fn cancelled_before_dispatch() -> ProviderError {
ProviderError::new(
ErrorClass::ActionCancelled,
"cancellation token was already cancelled; no wire request was sent",
RetryableSource::Classifier,
)
}
pub(crate) fn session_gone(method: &str) -> ProviderError {
ProviderError::new(
ErrorClass::TransportLost,
format!("provider session has ended; {method} is unavailable"),
RetryableSource::Classifier,
)
.with_client_code("transport_closed")
}
#[cfg(test)]
mod tests {
use super::*;
use devicerail_client::protocol::ErrorInfo as WireErrorInfo;
fn wire_error(code: &str, numeric: i32, retryable: bool) -> RpcError {
RpcError {
code: numeric,
message: format!("test {code}"),
data: WireErrorInfo {
code: code.to_owned(),
message: format!("test {code}"),
retryable,
details: None,
},
}
}
#[test]
fn action_layer_table_rows_map_exactly() {
let rows = [
("element_not_found", false, ErrorClass::ActionFailedFinal),
("element_ambiguous", false, ErrorClass::ActionFailedFinal),
("element_stale", true, ErrorClass::TargetStale),
("ui_context_changed", true, ErrorClass::TargetStale),
(
"device_unavailable",
true,
ErrorClass::ActionFailedRetryable,
),
("session_degraded", true, ErrorClass::SessionDegraded),
("invalid_arguments", false, ErrorClass::BindArgumentsInvalid),
("action_timeout", true, ErrorClass::ActionTimedOut),
("action_timed_out", true, ErrorClass::ActionTimedOut),
("request_timed_out", true, ErrorClass::ActionTimedOut),
("action_cancelled", false, ErrorClass::ActionCancelled),
("request_cancelled", true, ErrorClass::ActionCancelled),
];
for (code, retryable, expected) in rows {
let (class, _) = classify_wire_code(code, retryable);
assert_eq!(class, expected, "row {code}");
}
}
#[test]
fn table_rows_pin_the_class_regardless_of_the_wire_flag() {
let (class, source) = classify_wire_code("element_not_found", true);
assert_eq!(class, ErrorClass::ActionFailedFinal);
assert_eq!(source, RetryableSource::Classifier);
}
#[test]
fn open_set_codes_follow_the_daemon_retryable_bit() {
let (class, source) = classify_wire_code("device_not_connected", true);
assert_eq!(class, ErrorClass::ActionFailedRetryable);
assert_eq!(source, RetryableSource::Daemon);
let (class, source) = classify_wire_code("unknown_action", false);
assert_eq!(class, ErrorClass::ActionFailedFinal);
assert_eq!(source, RetryableSource::Daemon);
}
#[test]
fn capability_rows_outrank_the_open_set_in_envelope_errors() {
for code in [
"feature_not_negotiated",
"required_feature_unsupported",
"semantic_snapshot_dependency_unsatisfied",
] {
let (class, _) = classify_remote_rpc(&wire_error(code, -32004, true));
assert_eq!(class, ErrorClass::CapabilityDrift, "row {code}");
}
let (class, _) = classify_remote_rpc(&wire_error("invalid_params", -32602, false));
assert_eq!(class, ErrorClass::BindArgumentsInvalid);
let (class, _) = classify_remote_rpc(&wire_error("session_degraded", -32006, true));
assert_eq!(class, ErrorClass::SessionDegraded);
}
#[test]
fn remote_rpc_carrier_keeps_the_wire_error_and_client_code() {
let error = ClientError::RemoteRpc {
request_id: devicerail_client::protocol::RpcId::Number(1),
error: Box::new(wire_error("device_unavailable", -32000, true)),
};
let carrier = provider_error_from_client(error, "device.execute");
assert_eq!(carrier.error_class, ErrorClass::ActionFailedRetryable);
assert_eq!(carrier.retryable_source, RetryableSource::Daemon);
assert_eq!(carrier.client_code.as_deref(), Some("remote_rpc_error"));
let wire = carrier.wire.expect("wire error attached");
assert_eq!(wire.code, "device_unavailable");
assert!(wire.retryable);
}
#[test]
fn transport_family_maps_to_transport_lost() {
for (error, code) in [
(
ClientError::Transport("pipe closed".to_owned()),
"transport_closed",
),
(ClientError::Closed, "transport_closed"),
(
ClientError::ProtocolViolation("bad frame".to_owned()),
"protocol_violation",
),
(
ClientError::Framing(devicerail_client::FramingError::InvalidUtf8),
"ndjson_frame_error",
),
(
ClientError::Serialization("strict decode failed".to_owned()),
"serialization",
),
] {
let carrier = provider_error_from_client(error, "ctx");
assert_eq!(carrier.error_class, ErrorClass::TransportLost);
assert_eq!(carrier.client_code.as_deref(), Some(code));
}
}
#[test]
fn feature_not_negotiated_is_capability_drift() {
let error = ClientError::FeatureNotNegotiated {
method: "ui.snapshot.get",
feature: "observation.uiSnapshot.v1",
};
let carrier = provider_error_from_client(error, "ui.snapshot.get");
assert_eq!(carrier.error_class, ErrorClass::CapabilityDrift);
assert_eq!(
carrier.client_code.as_deref(),
Some("feature_not_negotiated")
);
}
#[test]
fn client_bug_family_is_final_but_terminal_phases_are_transport() {
for phase in ["closed", "failed", "closing"] {
let carrier = provider_error_from_client(ClientError::HandshakeState(phase), "ctx");
assert_eq!(carrier.error_class, ErrorClass::TransportLost, "{phase}");
}
let carrier = provider_error_from_client(ClientError::HandshakeState("ready"), "ctx");
assert_eq!(carrier.error_class, ErrorClass::ActionFailedFinal);
let carrier = provider_error_from_client(ClientError::Internal("bug".to_owned()), "ctx");
assert_eq!(carrier.error_class, ErrorClass::ActionFailedFinal);
}
#[test]
fn execute_rpc_failures_extract_definite_terminals() {
use pointlock_ir::ActionOutcome;
for code in ["request_cancelled", "action_cancelled"] {
let outcome = execute_terminal_from_rpc(&wire_error(code, -32007, true))
.expect("cancelled terminal");
assert_eq!(outcome.kind(), "cancelled");
}
for code in ["request_timed_out", "action_timed_out", "action_timeout"] {
let outcome = execute_terminal_from_rpc(&wire_error(code, -32008, true))
.expect("timedOut terminal");
assert_eq!(outcome.kind(), "timedOut");
}
let outcome = execute_terminal_from_rpc(&wire_error(
"element_not_found",
DRIVER_ERROR_RPC_CODE,
false,
))
.expect("failed terminal");
let ActionOutcome::Failed { error } = outcome else {
panic!("expected failed terminal");
};
assert_eq!(error.code, "element_not_found");
assert!(execute_terminal_from_rpc(&wire_error("session_required", -32005, true)).is_none());
assert!(
execute_terminal_from_rpc(&wire_error(
"semantic_channel_unavailable",
DRIVER_ERROR_RPC_CODE,
false
))
.is_none()
);
assert!(execute_terminal_from_rpc(&wire_error("invalid_params", -32602, false)).is_none());
}
}