pointlock_provider_kit/
error.rs1use std::fmt;
13
14use pointlock_ir::{ErrorClass, ErrorInfo};
15use schemars::JsonSchema;
16use serde::{Deserialize, Serialize};
17
18#[derive(
23 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
24)]
25#[serde(rename_all = "camelCase")]
26pub enum RetryableSource {
27 Daemon,
29 Classifier,
31}
32
33#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
36#[serde(rename_all = "camelCase", deny_unknown_fields)]
37pub struct ProviderError {
38 pub error_class: ErrorClass,
40 pub message: String,
42 #[serde(skip_serializing_if = "Option::is_none")]
45 pub wire: Option<Box<ErrorInfo>>,
46 #[serde(skip_serializing_if = "Option::is_none")]
50 pub client_code: Option<String>,
51 pub retryable_source: RetryableSource,
53}
54
55impl ProviderError {
56 pub fn new(
58 error_class: ErrorClass,
59 message: impl Into<String>,
60 retryable_source: RetryableSource,
61 ) -> Self {
62 ProviderError {
63 error_class,
64 message: message.into(),
65 wire: None,
66 client_code: None,
67 retryable_source,
68 }
69 }
70
71 pub fn with_wire(mut self, wire: ErrorInfo) -> Self {
73 self.wire = Some(Box::new(wire));
74 self
75 }
76
77 pub fn with_client_code(mut self, client_code: impl Into<String>) -> Self {
79 self.client_code = Some(client_code.into());
80 self
81 }
82}
83
84impl fmt::Display for ProviderError {
85 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86 let class = serde_json::to_value(self.error_class).expect("ErrorClass serializes");
88 let class = class.as_str().expect("ErrorClass serializes to a string");
89 write!(f, "provider error [{class}]: {}", self.message)
90 }
91}
92
93impl std::error::Error for ProviderError {}
94
95#[cfg(test)]
96mod tests {
97 use super::*;
98 use serde_json::json;
99
100 #[test]
101 fn provider_error_wire_shape() {
102 let error = ProviderError::new(
103 ErrorClass::TransportLost,
104 "daemon exited",
105 RetryableSource::Classifier,
106 )
107 .with_wire(ErrorInfo {
108 code: "session_degraded".to_owned(),
109 message: "adb bridge lost".to_owned(),
110 retryable: true,
111 details: None,
112 })
113 .with_client_code("transport_closed");
114
115 let wire = serde_json::to_value(&error).expect("serialize");
116 assert_eq!(wire["errorClass"], "transport_lost");
117 assert_eq!(wire["retryableSource"], "classifier");
118 assert_eq!(wire["clientCode"], "transport_closed");
119 assert_eq!(wire["wire"]["code"], "session_degraded");
120 let back: ProviderError = serde_json::from_value(wire).expect("deserialize");
121 assert_eq!(back, error);
122 }
123
124 #[test]
125 fn provider_error_optional_fields_absent_when_none() {
126 let error = ProviderError::new(
127 ErrorClass::CapabilityDrift,
128 "digest mismatch",
129 RetryableSource::Classifier,
130 );
131 let wire = serde_json::to_value(&error).expect("serialize");
132 assert_eq!(
133 wire,
134 json!({
135 "errorClass": "capability_drift",
136 "message": "digest mismatch",
137 "retryableSource": "classifier",
138 })
139 );
140 }
141
142 #[test]
143 fn provider_error_display_uses_wire_class_spelling() {
144 let error = ProviderError::new(
145 ErrorClass::ActionTimedOut,
146 "budget elapsed",
147 RetryableSource::Daemon,
148 );
149 assert_eq!(
150 error.to_string(),
151 "provider error [action_timed_out]: budget elapsed"
152 );
153 }
154}