openzeppelin_monitor/services/blockchain/clients/stellar/
error.rs

1//! Stellar client error types
2//!
3//! Provides error handling for Stellar RPC requests, response parsing, input validation and out-of-retention errors.
4
5use crate::utils::logging::error::{ErrorContext, TraceableError};
6use std::collections::HashMap;
7use thiserror::Error;
8
9/// Stellar client error type
10#[derive(Debug, Error)]
11pub enum StellarClientError {
12	/// Requested data is outside of the Stellar RPC retention window
13	#[error("Data for '{ledger_info}' is outside of Stellar RPC retention window")]
14	OutsideRetentionWindow {
15		rpc_code: i64,       // Code from RPC response
16		rpc_message: String, // Message from RPC response
17		ledger_info: String, // Information about the ledger (e.g., start_sequence, end_sequence)
18		context: Box<ErrorContext>,
19	},
20
21	/// Failure in making an RPC request
22	#[error("Stellar RPC request failed: {0}")]
23	RpcError(Box<ErrorContext>),
24
25	/// Failure in parsing the Stellar RPC response
26	#[error("Failed to parse Stellar RPC response: {0}")]
27	ResponseParseError(Box<ErrorContext>),
28
29	/// Invalid input provided to the Stellar client
30	#[error("Invalid input: {0}")]
31	InvalidInput(Box<ErrorContext>),
32
33	/// The response from the Stellar RPC does not match the expected format.
34	#[error("Unexpected response structure from Stellar RPC: {0}")]
35	UnexpectedResponseStructure(Box<ErrorContext>),
36}
37
38impl StellarClientError {
39	pub fn outside_retention_window(
40		rpc_code: i64,
41		rpc_message: String,
42		ledger_info: String,
43		source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
44		metadata: Option<HashMap<String, String>>,
45	) -> Self {
46		let message = format!(
47			"Data for '{}' is outside of Stellar RPC retention window: {} (code {})",
48			&ledger_info.clone(),
49			&rpc_message.clone(),
50			&rpc_code
51		);
52		Self::OutsideRetentionWindow {
53			rpc_code,
54			rpc_message,
55			ledger_info,
56			context: Box::new(ErrorContext::new_with_log(message, source, metadata)),
57		}
58	}
59
60	pub fn rpc_error(
61		message: impl Into<String>,
62		source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
63		metadata: Option<HashMap<String, String>>,
64	) -> Self {
65		Self::RpcError(Box::new(ErrorContext::new_with_log(
66			message, source, metadata,
67		)))
68	}
69
70	pub fn response_parse_error(
71		message: impl Into<String>,
72		source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
73		metadata: Option<HashMap<String, String>>,
74	) -> Self {
75		Self::ResponseParseError(Box::new(ErrorContext::new_with_log(
76			message, source, metadata,
77		)))
78	}
79
80	pub fn invalid_input(
81		msg: impl Into<String>,
82		source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
83		metadata: Option<HashMap<String, String>>,
84	) -> Self {
85		Self::InvalidInput(Box::new(ErrorContext::new_with_log(msg, source, metadata)))
86	}
87
88	pub fn unexpected_response_structure(
89		msg: impl Into<String>,
90		source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
91		metadata: Option<HashMap<String, String>>,
92	) -> Self {
93		Self::UnexpectedResponseStructure(Box::new(ErrorContext::new_with_log(
94			msg, source, metadata,
95		)))
96	}
97}
98
99impl TraceableError for StellarClientError {
100	fn trace_id(&self) -> String {
101		match self {
102			StellarClientError::OutsideRetentionWindow { context, .. } => context.trace_id.clone(),
103			StellarClientError::RpcError(context) => context.trace_id.clone(),
104			StellarClientError::ResponseParseError(context) => context.trace_id.clone(),
105			StellarClientError::InvalidInput(context) => context.trace_id.clone(),
106			StellarClientError::UnexpectedResponseStructure(context) => context.trace_id.clone(),
107		}
108	}
109}
110
111#[cfg(test)]
112mod tests {
113	use super::*;
114
115	#[test]
116	fn test_outside_retention_window_error_formatting() {
117		let rpc_error_code = 1234;
118		let rpc_error_message = "Random RPC error".to_string();
119		let error_ledger_info = "start_sequence=123 end_sequence=456".to_string();
120		let error = StellarClientError::outside_retention_window(
121			rpc_error_code,
122			rpc_error_message.clone(),
123			error_ledger_info.clone(),
124			None,
125			None,
126		);
127		assert_eq!(
128			error.to_string(),
129			format!(
130				"Data for '{}' is outside of Stellar RPC retention window",
131				error_ledger_info
132			)
133		);
134		if let StellarClientError::OutsideRetentionWindow {
135			rpc_code,
136			rpc_message,
137			ledger_info,
138			context,
139		} = error
140		{
141			assert_eq!(rpc_code, rpc_error_code);
142			assert_eq!(rpc_message, rpc_error_message);
143			assert_eq!(ledger_info, error_ledger_info);
144			assert!(!context.trace_id.is_empty());
145		} else {
146			panic!("Expected OutsideRetentionWindow variant");
147		}
148	}
149
150	#[test]
151	fn test_rpc_error_formatting() {
152		let error_message = "Random Stellar RPC error".to_string();
153		let error = StellarClientError::rpc_error(error_message.clone(), None, None);
154		assert_eq!(
155			error.to_string(),
156			format!("Stellar RPC request failed: {}", error_message)
157		);
158		if let StellarClientError::RpcError(context) = error {
159			assert_eq!(context.message, error_message);
160			assert!(!context.trace_id.is_empty());
161		} else {
162			panic!("Expected RpcError variant");
163		}
164	}
165
166	#[test]
167	fn test_response_parse_error_formatting() {
168		let error_message = "Failed to parse Stellar RPC response".to_string();
169		let error = StellarClientError::response_parse_error(error_message.clone(), None, None);
170		assert_eq!(
171			error.to_string(),
172			format!("Failed to parse Stellar RPC response: {}", error_message)
173		);
174		if let StellarClientError::ResponseParseError(context) = error {
175			assert_eq!(context.message, error_message);
176			assert!(!context.trace_id.is_empty());
177		} else {
178			panic!("Expected ResponseParseError variant");
179		}
180	}
181
182	#[test]
183	fn test_invalid_input_error_formatting() {
184		let error_message = "Invalid input provided to Stellar client".to_string();
185		let error = StellarClientError::invalid_input(error_message.clone(), None, None);
186		assert_eq!(
187			error.to_string(),
188			format!("Invalid input: {}", error_message)
189		);
190		if let StellarClientError::InvalidInput(context) = error {
191			assert_eq!(context.message, error_message);
192			assert!(!context.trace_id.is_empty());
193		} else {
194			panic!("Expected InvalidInput variant");
195		}
196	}
197
198	#[test]
199	fn test_unexpected_response_structure_error_formatting() {
200		let error_message = "Unexpected response structure from Stellar RPC".to_string();
201		let error =
202			StellarClientError::unexpected_response_structure(error_message.clone(), None, None);
203		assert_eq!(
204			error.to_string(),
205			format!(
206				"Unexpected response structure from Stellar RPC: {}",
207				error_message
208			)
209		);
210		if let StellarClientError::UnexpectedResponseStructure(context) = error {
211			assert_eq!(context.message, error_message);
212			assert!(!context.trace_id.is_empty());
213		} else {
214			panic!("Expected UnexpectedResponseStructure variant");
215		}
216	}
217
218	#[test]
219	fn test_error_source_chain() {
220		let io_error = std::io::Error::new(std::io::ErrorKind::Other, "while reading config");
221
222		let outer_error =
223			StellarClientError::rpc_error("Failed to initialize", Some(Box::new(io_error)), None);
224
225		// Just test the string representation instead of the source chain
226		assert!(outer_error.to_string().contains("Failed to initialize"));
227
228		// For StellarClientError::RpcError, we know the implementation details
229		if let StellarClientError::RpcError(context) = &outer_error {
230			// Check that the context has the right message
231			assert_eq!(context.message, "Failed to initialize");
232
233			// Check that the context has the source error
234			assert!(context.source.is_some());
235
236			if let Some(src) = &context.source {
237				assert_eq!(src.to_string(), "while reading config");
238			}
239		} else {
240			panic!("Expected RpcError variant");
241		}
242	}
243
244	#[test]
245	fn test_all_error_variants_have_and_propagate_consistent_trace_id() {
246		let create_context_with_id = || {
247			let context = ErrorContext::new("test message", None, None);
248			let original_id = context.trace_id.clone();
249			(context, original_id)
250		};
251
252		let errors_with_ids: Vec<(StellarClientError, String)> = vec![
253			{
254				let (ctx, id) = create_context_with_id();
255				(StellarClientError::RpcError(Box::new(ctx)), id)
256			},
257			{
258				let (ctx, id) = create_context_with_id();
259				(StellarClientError::ResponseParseError(Box::new(ctx)), id)
260			},
261			{
262				let (ctx, id) = create_context_with_id();
263				(StellarClientError::InvalidInput(Box::new(ctx)), id)
264			},
265			{
266				let (ctx, id) = create_context_with_id();
267				(
268					StellarClientError::OutsideRetentionWindow {
269						rpc_code: 0,
270						rpc_message: "".to_string(),
271						ledger_info: "".to_string(),
272						context: Box::new(ctx),
273					},
274					id,
275				)
276			},
277			{
278				let (ctx, id) = create_context_with_id();
279				(
280					StellarClientError::UnexpectedResponseStructure(Box::new(ctx)),
281					id,
282				)
283			},
284		];
285
286		for (error, original_id) in errors_with_ids {
287			let propagated_id = error.trace_id();
288			assert!(
289				!propagated_id.is_empty(),
290				"Error {:?} should have a non-empty trace_id",
291				error
292			);
293			assert_eq!(
294				propagated_id, original_id,
295				"Trace ID for {:?} was not propagated consistently. Expected: {}, Got: {}",
296				error, original_id, propagated_id
297			);
298		}
299	}
300}