1use devicerail_client::ClientError;
22use devicerail_client::protocol::RpcError;
23use pointlock_ir::ErrorClass;
24use pointlock_provider_kit::{ProviderError, RetryableSource};
25
26use crate::convert::error_info_from_wire;
27
28pub const DRIVER_ERROR_RPC_CODE: i32 = -32000;
36
37pub fn classify_wire_code(code: &str, retryable: bool) -> (ErrorClass, RetryableSource) {
41 match code {
42 "element_not_found" | "element_ambiguous" => {
45 (ErrorClass::ActionFailedFinal, RetryableSource::Classifier)
46 }
47 "element_stale" | "ui_context_changed" => {
49 (ErrorClass::TargetStale, RetryableSource::Classifier)
50 }
51 "device_unavailable" => (ErrorClass::ActionFailedRetryable, RetryableSource::Daemon),
53 "session_degraded" => (ErrorClass::SessionDegraded, RetryableSource::Classifier),
54 "invalid_arguments" => (
57 ErrorClass::BindArgumentsInvalid,
58 RetryableSource::Classifier,
59 ),
60 "action_timeout" | "action_timed_out" | "request_timed_out" => {
64 (ErrorClass::ActionTimedOut, RetryableSource::Classifier)
65 }
66 "action_cancelled" | "request_cancelled" => {
69 (ErrorClass::ActionCancelled, RetryableSource::Classifier)
70 }
71 _ if retryable => (ErrorClass::ActionFailedRetryable, RetryableSource::Daemon),
74 _ => (ErrorClass::ActionFailedFinal, RetryableSource::Daemon),
75 }
76}
77
78pub fn classify_remote_rpc(error: &RpcError) -> (ErrorClass, RetryableSource) {
81 match error.data.code.as_str() {
82 "feature_not_negotiated"
86 | "required_feature_unsupported"
87 | "semantic_snapshot_dependency_unsatisfied" => {
88 (ErrorClass::CapabilityDrift, RetryableSource::Classifier)
89 }
90 "invalid_params" => (
93 ErrorClass::BindArgumentsInvalid,
94 RetryableSource::Classifier,
95 ),
96 code => classify_wire_code(code, error.data.retryable),
97 }
98}
99
100pub fn provider_error_from_client(error: ClientError, context: &str) -> ProviderError {
104 let message = format!("{context}: {error}");
105 match error {
106 ClientError::RemoteRpc { error, .. } => {
107 let (class, source) = classify_remote_rpc(&error);
108 ProviderError::new(class, message, source)
109 .with_wire(error_info_from_wire(&error.data))
110 .with_client_code("remote_rpc_error")
111 }
112 ClientError::FeatureNotNegotiated { .. } => ProviderError::new(
113 ErrorClass::CapabilityDrift,
114 message,
115 RetryableSource::Classifier,
116 )
117 .with_client_code("feature_not_negotiated"),
118 ClientError::Transport(_) | ClientError::Closed => ProviderError::new(
121 ErrorClass::TransportLost,
122 message,
123 RetryableSource::Classifier,
124 )
125 .with_client_code("transport_closed"),
126 ClientError::Framing(_) => ProviderError::new(
127 ErrorClass::TransportLost,
128 message,
129 RetryableSource::Classifier,
130 )
131 .with_client_code("ndjson_frame_error"),
132 ClientError::ProtocolViolation(_) => ProviderError::new(
133 ErrorClass::TransportLost,
134 message,
135 RetryableSource::Classifier,
136 )
137 .with_client_code("protocol_violation"),
138 ClientError::Serialization(_) => ProviderError::new(
141 ErrorClass::TransportLost,
142 message,
143 RetryableSource::Classifier,
144 )
145 .with_client_code("serialization"),
146 ClientError::PendingRequestLimit(_)
151 | ClientError::AbandonedRequestLimit(_)
152 | ClientError::WriteQueueFull { .. } => ProviderError::new(
153 ErrorClass::TransportLost,
154 message,
155 RetryableSource::Classifier,
156 )
157 .with_client_code("write_queue_overflow"),
158 ClientError::WriteFrameTooLarge { .. } => ProviderError::new(
159 ErrorClass::TransportLost,
160 message,
161 RetryableSource::Classifier,
162 )
163 .with_client_code("write_frame_too_large"),
164 ClientError::HandshakeState(state @ ("failed" | "closed" | "closing")) => {
169 ProviderError::new(
170 ErrorClass::TransportLost,
171 message,
172 RetryableSource::Classifier,
173 )
174 .with_client_code(format!("handshake_state:{state}"))
175 }
176 ClientError::HandshakeState(_) => ProviderError::new(
177 ErrorClass::ActionFailedFinal,
178 message,
179 RetryableSource::Classifier,
180 )
181 .with_client_code("handshake_state"),
182 ClientError::RuntimeUnavailable | ClientError::Internal(_) => ProviderError::new(
183 ErrorClass::ActionFailedFinal,
184 message,
185 RetryableSource::Classifier,
186 )
187 .with_client_code("internal"),
188 }
189}
190
191pub fn execute_terminal_from_rpc(error: &RpcError) -> Option<pointlock_ir::ActionOutcome> {
211 use pointlock_ir::ActionOutcome;
212 let info = error_info_from_wire(&error.data);
213 match error.data.code.as_str() {
214 "request_cancelled" | "action_cancelled" => Some(ActionOutcome::Cancelled { error: info }),
215 "request_timed_out" | "action_timed_out" | "action_timeout" => {
216 Some(ActionOutcome::TimedOut { error: info })
217 }
218 "semantic_channel_unavailable" => None,
220 _ if error.code == DRIVER_ERROR_RPC_CODE => Some(ActionOutcome::Failed { error: info }),
221 _ => None,
222 }
223}
224
225pub(crate) fn cancelled_before_dispatch() -> ProviderError {
228 ProviderError::new(
229 ErrorClass::ActionCancelled,
230 "cancellation token was already cancelled; no wire request was sent",
231 RetryableSource::Classifier,
232 )
233}
234
235pub(crate) fn session_gone(method: &str) -> ProviderError {
238 ProviderError::new(
239 ErrorClass::TransportLost,
240 format!("provider session has ended; {method} is unavailable"),
241 RetryableSource::Classifier,
242 )
243 .with_client_code("transport_closed")
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249 use devicerail_client::protocol::ErrorInfo as WireErrorInfo;
250
251 fn wire_error(code: &str, numeric: i32, retryable: bool) -> RpcError {
252 RpcError {
253 code: numeric,
254 message: format!("test {code}"),
255 data: WireErrorInfo {
256 code: code.to_owned(),
257 message: format!("test {code}"),
258 retryable,
259 details: None,
260 },
261 }
262 }
263
264 #[test]
265 fn action_layer_table_rows_map_exactly() {
266 let rows = [
268 ("element_not_found", false, ErrorClass::ActionFailedFinal),
269 ("element_ambiguous", false, ErrorClass::ActionFailedFinal),
270 ("element_stale", true, ErrorClass::TargetStale),
271 ("ui_context_changed", true, ErrorClass::TargetStale),
272 (
273 "device_unavailable",
274 true,
275 ErrorClass::ActionFailedRetryable,
276 ),
277 ("session_degraded", true, ErrorClass::SessionDegraded),
278 ("invalid_arguments", false, ErrorClass::BindArgumentsInvalid),
279 ("action_timeout", true, ErrorClass::ActionTimedOut),
280 ("action_timed_out", true, ErrorClass::ActionTimedOut),
281 ("request_timed_out", true, ErrorClass::ActionTimedOut),
282 ("action_cancelled", false, ErrorClass::ActionCancelled),
283 ("request_cancelled", true, ErrorClass::ActionCancelled),
284 ];
285 for (code, retryable, expected) in rows {
286 let (class, _) = classify_wire_code(code, retryable);
287 assert_eq!(class, expected, "row {code}");
288 }
289 }
290
291 #[test]
292 fn table_rows_pin_the_class_regardless_of_the_wire_flag() {
293 let (class, source) = classify_wire_code("element_not_found", true);
297 assert_eq!(class, ErrorClass::ActionFailedFinal);
298 assert_eq!(source, RetryableSource::Classifier);
299 }
300
301 #[test]
302 fn open_set_codes_follow_the_daemon_retryable_bit() {
303 let (class, source) = classify_wire_code("device_not_connected", true);
304 assert_eq!(class, ErrorClass::ActionFailedRetryable);
305 assert_eq!(source, RetryableSource::Daemon);
306
307 let (class, source) = classify_wire_code("unknown_action", false);
308 assert_eq!(class, ErrorClass::ActionFailedFinal);
309 assert_eq!(source, RetryableSource::Daemon);
310 }
311
312 #[test]
313 fn capability_rows_outrank_the_open_set_in_envelope_errors() {
314 for code in [
315 "feature_not_negotiated",
316 "required_feature_unsupported",
317 "semantic_snapshot_dependency_unsatisfied",
318 ] {
319 let (class, _) = classify_remote_rpc(&wire_error(code, -32004, true));
320 assert_eq!(class, ErrorClass::CapabilityDrift, "row {code}");
321 }
322 let (class, _) = classify_remote_rpc(&wire_error("invalid_params", -32602, false));
323 assert_eq!(class, ErrorClass::BindArgumentsInvalid);
324 let (class, _) = classify_remote_rpc(&wire_error("session_degraded", -32006, true));
325 assert_eq!(class, ErrorClass::SessionDegraded);
326 }
327
328 #[test]
329 fn remote_rpc_carrier_keeps_the_wire_error_and_client_code() {
330 let error = ClientError::RemoteRpc {
331 request_id: devicerail_client::protocol::RpcId::Number(1),
332 error: Box::new(wire_error("device_unavailable", -32000, true)),
333 };
334 let carrier = provider_error_from_client(error, "device.execute");
335 assert_eq!(carrier.error_class, ErrorClass::ActionFailedRetryable);
336 assert_eq!(carrier.retryable_source, RetryableSource::Daemon);
337 assert_eq!(carrier.client_code.as_deref(), Some("remote_rpc_error"));
338 let wire = carrier.wire.expect("wire error attached");
339 assert_eq!(wire.code, "device_unavailable");
340 assert!(wire.retryable);
341 }
342
343 #[test]
344 fn transport_family_maps_to_transport_lost() {
345 for (error, code) in [
346 (
347 ClientError::Transport("pipe closed".to_owned()),
348 "transport_closed",
349 ),
350 (ClientError::Closed, "transport_closed"),
351 (
352 ClientError::ProtocolViolation("bad frame".to_owned()),
353 "protocol_violation",
354 ),
355 (
356 ClientError::Framing(devicerail_client::FramingError::InvalidUtf8),
357 "ndjson_frame_error",
358 ),
359 (
360 ClientError::Serialization("strict decode failed".to_owned()),
361 "serialization",
362 ),
363 ] {
364 let carrier = provider_error_from_client(error, "ctx");
365 assert_eq!(carrier.error_class, ErrorClass::TransportLost);
366 assert_eq!(carrier.client_code.as_deref(), Some(code));
367 }
368 }
369
370 #[test]
371 fn feature_not_negotiated_is_capability_drift() {
372 let error = ClientError::FeatureNotNegotiated {
373 method: "ui.snapshot.get",
374 feature: "observation.uiSnapshot.v1",
375 };
376 let carrier = provider_error_from_client(error, "ui.snapshot.get");
377 assert_eq!(carrier.error_class, ErrorClass::CapabilityDrift);
378 assert_eq!(
379 carrier.client_code.as_deref(),
380 Some("feature_not_negotiated")
381 );
382 }
383
384 #[test]
385 fn client_bug_family_is_final_but_terminal_phases_are_transport() {
386 for phase in ["closed", "failed", "closing"] {
388 let carrier = provider_error_from_client(ClientError::HandshakeState(phase), "ctx");
389 assert_eq!(carrier.error_class, ErrorClass::TransportLost, "{phase}");
390 }
391 let carrier = provider_error_from_client(ClientError::HandshakeState("ready"), "ctx");
393 assert_eq!(carrier.error_class, ErrorClass::ActionFailedFinal);
394 let carrier = provider_error_from_client(ClientError::Internal("bug".to_owned()), "ctx");
395 assert_eq!(carrier.error_class, ErrorClass::ActionFailedFinal);
396 }
397
398 #[test]
399 fn execute_rpc_failures_extract_definite_terminals() {
400 use pointlock_ir::ActionOutcome;
401
402 for code in ["request_cancelled", "action_cancelled"] {
404 let outcome = execute_terminal_from_rpc(&wire_error(code, -32007, true))
405 .expect("cancelled terminal");
406 assert_eq!(outcome.kind(), "cancelled");
407 }
408 for code in ["request_timed_out", "action_timed_out", "action_timeout"] {
410 let outcome = execute_terminal_from_rpc(&wire_error(code, -32008, true))
411 .expect("timedOut terminal");
412 assert_eq!(outcome.kind(), "timedOut");
413 }
414 let outcome = execute_terminal_from_rpc(&wire_error(
417 "element_not_found",
418 DRIVER_ERROR_RPC_CODE,
419 false,
420 ))
421 .expect("failed terminal");
422 let ActionOutcome::Failed { error } = outcome else {
423 panic!("expected failed terminal");
424 };
425 assert_eq!(error.code, "element_not_found");
426
427 assert!(execute_terminal_from_rpc(&wire_error("session_required", -32005, true)).is_none());
429 assert!(
430 execute_terminal_from_rpc(&wire_error(
431 "semantic_channel_unavailable",
432 DRIVER_ERROR_RPC_CODE,
433 false
434 ))
435 .is_none()
436 );
437 assert!(execute_terminal_from_rpc(&wire_error("invalid_params", -32602, false)).is_none());
438 }
439}