Skip to content

Commit 0e04cfb

Browse files
authored
feat: Introduce TransportError (#259)
1 parent 05a1b54 commit 0e04cfb

File tree

11 files changed

+431
-48
lines changed

11 files changed

+431
-48
lines changed

src/services/blockchain/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,5 @@ pub use error::BlockChainError;
2121
pub use pool::{ClientPool, ClientPoolTrait};
2222
pub use transports::{
2323
BlockchainTransport, EVMTransportClient, EndpointManager, HttpTransportClient,
24-
RotatingTransport, StellarTransportClient, TransientErrorRetryStrategy,
24+
RotatingTransport, StellarTransportClient, TransientErrorRetryStrategy, TransportError,
2525
};

src/services/blockchain/transports/endpoint_manager.rs

Lines changed: 63 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ use serde_json::Value;
99
use std::sync::Arc;
1010
use tokio::sync::RwLock;
1111

12-
use crate::services::blockchain::transports::{RotatingTransport, ROTATE_ON_ERROR_CODES};
12+
use crate::services::blockchain::transports::{
13+
RotatingTransport, TransportError, ROTATE_ON_ERROR_CODES,
14+
};
1315

1416
/// Manages the rotation of blockchain RPC endpoints
1517
///
@@ -185,7 +187,7 @@ impl EndpointManager {
185187
/// * `params` - The parameters for the RPC method call as a JSON Value
186188
///
187189
/// # Returns
188-
/// * `Result<Value, anyhow::Error>` - The JSON response from the RPC endpoint or an error
190+
/// * `Result<Value, TransportError>` - The JSON response from the RPC endpoint or an error
189191
///
190192
/// # Behavior
191193
/// - Automatically rotates to fallback URLs if the request fails with specific status codes
@@ -200,31 +202,51 @@ impl EndpointManager {
200202
transport: &T,
201203
method: &str,
202204
params: Option<P>,
203-
) -> Result<Value, anyhow::Error> {
205+
) -> Result<Value, TransportError> {
204206
loop {
205207
let current_url = self.active_url.read().await.clone();
206208
let request_body = transport.customize_request(method, params.clone()).await;
207209

208-
let response = match self
210+
let response_result = self
209211
.client
210212
.post(current_url.as_str())
211213
.header("Content-Type", "application/json")
212-
.body(
213-
serde_json::to_string(&request_body)
214-
.map_err(|e| anyhow::anyhow!("Failed to parse request: {}", e))?,
215-
)
214+
.body(serde_json::to_string(&request_body).map_err(|e| {
215+
TransportError::request_serialization(
216+
"Failed to serialize request JSON",
217+
Some(Box::new(e)),
218+
None,
219+
)
220+
})?)
216221
.send()
217-
.await
218-
{
222+
.await;
223+
224+
let response = match response_result {
219225
Ok(resp) => resp,
220-
Err(e) => {
221-
tracing::warn!("Network error while sending request: {}", e);
226+
Err(network_error) => {
227+
tracing::warn!("Network error while sending request: {}", network_error);
222228
// Try rotation for network errors without status check
223-
if self.should_attempt_rotation(transport, false, None).await? {
224-
continue;
229+
let should_rotate = self.should_attempt_rotation(transport, false, None).await;
230+
231+
match should_rotate {
232+
Ok(true) => continue,
233+
Ok(false) => {
234+
return Err(TransportError::network(
235+
"Failed to send request due to network error",
236+
Some(network_error.into()),
237+
None,
238+
))
239+
}
240+
Err(rotation_error) => {
241+
let msg = "Failed to rotate URL after network error".to_string();
242+
Err(TransportError::url_rotation(
243+
msg,
244+
Some(rotation_error.into()),
245+
None,
246+
))
247+
}
225248
}
226-
return Err(anyhow::anyhow!("Failed to send request: {}", e));
227-
}
249+
}?,
228250
};
229251

230252
let status = response.status();
@@ -233,19 +255,35 @@ impl EndpointManager {
233255
tracing::warn!("Request failed with status {}: {}", status, error_body);
234256

235257
// Try rotation with status code check
236-
if self
258+
let should_rotate = self
237259
.should_attempt_rotation(transport, true, Some(status.as_u16()))
238-
.await?
239-
{
240-
continue;
260+
.await;
261+
262+
match should_rotate {
263+
Ok(true) => continue,
264+
Ok(false) => {
265+
return Err(TransportError::http(
266+
status,
267+
current_url,
268+
error_body.clone(),
269+
None,
270+
None,
271+
));
272+
}
273+
Err(e) => {
274+
let msg = "Failed to rotate URL after HTTP error".to_string();
275+
return Err(TransportError::url_rotation(msg, Some(e.into()), None));
276+
}
241277
}
242-
return Err(anyhow::anyhow!("HTTP error {}: {}", status, error_body));
243278
}
244279

245-
return response
246-
.json()
247-
.await
248-
.map_err(|e| anyhow::anyhow!("Failed to parse response: {}", e));
280+
return response.json().await.map_err(|e| {
281+
TransportError::response_parse(
282+
"Failed to parse JSON response",
283+
Some(Box::new(e)),
284+
None,
285+
)
286+
});
249287
}
250288
}
251289
}
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
//! Error types for blockchain transport services
2+
//!
3+
//! Provides error handling for network communication, JSON parsing, request serialization and URL rotation.
4+
5+
use crate::utils::logging::error::{ErrorContext, TraceableError};
6+
use std::collections::HashMap;
7+
use thiserror::Error;
8+
9+
#[derive(Debug, Error)]
10+
pub enum TransportError {
11+
/// HTTP error
12+
#[error("HTTP error: status {status_code} for URL {url}")]
13+
Http {
14+
status_code: reqwest::StatusCode,
15+
url: String,
16+
body: String,
17+
context: ErrorContext,
18+
},
19+
20+
/// Network error
21+
#[error("Network error: {0}")]
22+
Network(ErrorContext),
23+
24+
/// JSON parsing error
25+
#[error("Failed to parse JSON response: {0}")]
26+
ResponseParse(ErrorContext),
27+
28+
/// Request body serialization error
29+
#[error("Failed to serialize request JSON: {0}")]
30+
RequestSerialization(ErrorContext),
31+
32+
/// URL rotation error
33+
#[error("URL rotation failed: {0}")]
34+
UrlRotation(ErrorContext),
35+
}
36+
37+
impl TransportError {
38+
pub fn http(
39+
status_code: reqwest::StatusCode,
40+
url: String,
41+
body: String,
42+
source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
43+
metadata: Option<HashMap<String, String>>,
44+
) -> Self {
45+
let msg = format!("HTTP error: status {} for URL {}", status_code, url);
46+
47+
Self::Http {
48+
status_code,
49+
url,
50+
body,
51+
context: ErrorContext::new_with_log(msg, source, metadata),
52+
}
53+
}
54+
55+
pub fn network(
56+
msg: impl Into<String>,
57+
source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
58+
metadata: Option<HashMap<String, String>>,
59+
) -> Self {
60+
Self::Network(ErrorContext::new_with_log(msg, source, metadata))
61+
}
62+
63+
pub fn response_parse(
64+
msg: impl Into<String>,
65+
source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
66+
metadata: Option<HashMap<String, String>>,
67+
) -> Self {
68+
Self::ResponseParse(ErrorContext::new_with_log(msg, source, metadata))
69+
}
70+
71+
pub fn request_serialization(
72+
msg: impl Into<String>,
73+
source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
74+
metadata: Option<HashMap<String, String>>,
75+
) -> Self {
76+
Self::RequestSerialization(ErrorContext::new_with_log(msg, source, metadata))
77+
}
78+
pub fn url_rotation(
79+
msg: impl Into<String>,
80+
source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
81+
metadata: Option<HashMap<String, String>>,
82+
) -> Self {
83+
Self::UrlRotation(ErrorContext::new_with_log(msg, source, metadata))
84+
}
85+
}
86+
87+
impl TraceableError for TransportError {
88+
fn trace_id(&self) -> String {
89+
match self {
90+
Self::Http { context, .. } => context.trace_id.clone(),
91+
Self::Network(ctx) => ctx.trace_id.clone(),
92+
Self::ResponseParse(ctx) => ctx.trace_id.clone(),
93+
Self::RequestSerialization(ctx) => ctx.trace_id.clone(),
94+
Self::UrlRotation(ctx) => ctx.trace_id.clone(),
95+
}
96+
}
97+
}
98+
99+
#[cfg(test)]
100+
mod tests {
101+
use super::*;
102+
use std::io::{Error as IoError, ErrorKind};
103+
104+
#[test]
105+
fn test_http_error_formatting() {
106+
let error = TransportError::http(
107+
reqwest::StatusCode::NOT_FOUND,
108+
"http://example.com".to_string(),
109+
"Not Found".to_string(),
110+
None,
111+
None,
112+
);
113+
assert_eq!(
114+
error.to_string(),
115+
"HTTP error: status 404 Not Found for URL http://example.com"
116+
);
117+
}
118+
119+
#[test]
120+
fn test_network_error_formatting() {
121+
let error = TransportError::network("test error", None, None);
122+
assert_eq!(error.to_string(), "Network error: test error");
123+
124+
let source_error = IoError::new(ErrorKind::NotFound, "test source");
125+
let error = TransportError::network(
126+
"test error",
127+
Some(Box::new(source_error)),
128+
Some(HashMap::from([("key1".to_string(), "value1".to_string())])),
129+
);
130+
assert_eq!(error.to_string(), "Network error: test error [key1=value1]");
131+
}
132+
133+
#[test]
134+
fn test_response_parse_error_formatting() {
135+
let error = TransportError::response_parse("test error", None, None);
136+
assert_eq!(
137+
error.to_string(),
138+
"Failed to parse JSON response: test error"
139+
);
140+
141+
let source_error = IoError::new(ErrorKind::NotFound, "test source");
142+
let error = TransportError::response_parse(
143+
"test error",
144+
Some(Box::new(source_error)),
145+
Some(HashMap::from([("key1".to_string(), "value1".to_string())])),
146+
);
147+
assert_eq!(
148+
error.to_string(),
149+
"Failed to parse JSON response: test error [key1=value1]"
150+
);
151+
}
152+
153+
#[test]
154+
fn test_request_serialization_error_formatting() {
155+
let error = TransportError::request_serialization("test error", None, None);
156+
assert_eq!(
157+
error.to_string(),
158+
"Failed to serialize request JSON: test error"
159+
);
160+
161+
let source_error = IoError::new(ErrorKind::NotFound, "test source");
162+
let error = TransportError::request_serialization(
163+
"test error",
164+
Some(Box::new(source_error)),
165+
Some(HashMap::from([("key1".to_string(), "value1".to_string())])),
166+
);
167+
assert_eq!(
168+
error.to_string(),
169+
"Failed to serialize request JSON: test error [key1=value1]"
170+
);
171+
}
172+
173+
#[test]
174+
fn test_url_rotation_error_formatting() {
175+
let error = TransportError::url_rotation("test error", None, None);
176+
assert_eq!(error.to_string(), "URL rotation failed: test error");
177+
178+
let source_error = IoError::new(ErrorKind::NotFound, "test source");
179+
let error = TransportError::url_rotation(
180+
"test error",
181+
Some(Box::new(source_error)),
182+
Some(HashMap::from([("key1".to_string(), "value1".to_string())])),
183+
);
184+
assert_eq!(
185+
error.to_string(),
186+
"URL rotation failed: test error [key1=value1]"
187+
);
188+
}
189+
190+
#[test]
191+
fn test_error_source_chain() {
192+
let io_error = std::io::Error::new(std::io::ErrorKind::Other, "while reading config");
193+
194+
let outer_error = TransportError::http(
195+
reqwest::StatusCode::INTERNAL_SERVER_ERROR,
196+
"http://example.com".to_string(),
197+
"Internal Server Error".to_string(),
198+
Some(Box::new(io_error)),
199+
None,
200+
);
201+
202+
// Just test the string representation instead of the source chain
203+
assert!(outer_error.to_string().contains("Internal Server Error"));
204+
205+
// For TransportError::Http, we know the implementation details
206+
if let TransportError::Http { context, .. } = &outer_error {
207+
// Check that the context has the right message
208+
assert_eq!(
209+
context.message,
210+
"HTTP error: status 500 Internal Server Error for URL http://example.com"
211+
);
212+
213+
// Check that the context has the source error
214+
assert!(context.source.is_some());
215+
216+
if let Some(src) = &context.source {
217+
assert_eq!(src.to_string(), "while reading config");
218+
}
219+
} else {
220+
panic!("Expected Http variant");
221+
}
222+
}
223+
224+
#[test]
225+
fn test_trace_id_propagation() {
226+
// Create an error context with a known trace ID
227+
let error_context = ErrorContext::new("Inner error", None, None);
228+
let original_trace_id = error_context.trace_id.clone();
229+
230+
// Wrap it in a TransportError
231+
let transport_error = TransportError::Http {
232+
status_code: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
233+
url: "http://example.com".to_string(),
234+
body: "Internal Server Error".to_string(),
235+
context: error_context,
236+
};
237+
238+
// Verify the trace ID is preserved
239+
assert_eq!(transport_error.trace_id(), original_trace_id);
240+
241+
// Test trace ID propagation through error chain
242+
let source_error = IoError::new(ErrorKind::Other, "Source error");
243+
let error_context = ErrorContext::new("Middle error", Some(Box::new(source_error)), None);
244+
let original_trace_id = error_context.trace_id.clone();
245+
246+
let transport_error = TransportError::Http {
247+
status_code: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
248+
url: "http://example.com".to_string(),
249+
body: "Internal Server Error".to_string(),
250+
context: error_context,
251+
};
252+
assert_eq!(transport_error.trace_id(), original_trace_id);
253+
}
254+
}

0 commit comments

Comments
 (0)