cloudiful_docling_convert/
error.rs1use std::error::Error;
2use std::fmt;
3
4#[derive(Debug)]
5pub enum PdfConvertError {
6 IoError {
7 context: String,
8 source: std::io::Error,
9 },
10
11 ApiError {
12 status_code: Option<u16>,
13 message: String,
14 source: Option<reqwest::Error>,
15 },
16
17 ParseError {
18 target: String,
19 message: String,
20 },
21
22 #[allow(dead_code)]
23 ValidationError {
24 parameter: String,
25 reason: String,
26 },
27
28 EnvError {
29 var_name: String,
30 message: String,
31 },
32
33 OperationError {
34 context: String,
35 message: String,
36 },
37}
38
39impl fmt::Display for PdfConvertError {
40 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41 match self {
42 PdfConvertError::IoError { context, source } => {
43 write!(f, "IO error while {}: {}", context, source)?;
44 let mut curr = source.source();
45 while let Some(src) = curr {
46 write!(f, " caused by: {}", src)?;
47 curr = src.source();
48 }
49 Ok(())
50 }
51 PdfConvertError::ApiError {
52 status_code,
53 message,
54 source,
55 } => {
56 if let Some(src) = source {
57 let effective =
65 (*status_code).or_else(|| src.status().map(|status| status.as_u16()));
66 if let Some(code) = effective {
67 write!(f, "HTTP {}: {}", code, src)?;
68 } else {
69 write!(f, "{}", src)?;
70 }
71 let mut curr = src.source();
72 while let Some(cause) = curr {
73 write!(f, " caused by: {}", cause)?;
74 curr = cause.source();
75 }
76 if message != &src.to_string() && !message.is_empty() {
80 write!(f, " ({})", message)?;
81 }
82 Ok(())
83 } else if let Some(code) = status_code {
84 write!(f, "HTTP {}: {}", code, message)
85 } else {
86 write!(f, "{}", message)
87 }
88 }
89 PdfConvertError::ParseError { target, message } => {
90 write!(f, "Failed to parse {}: {}", target, message)
91 }
92 PdfConvertError::ValidationError { parameter, reason } => {
93 write!(f, "Validation error for '{}': {}", parameter, reason)
94 }
95 PdfConvertError::EnvError { var_name, message } => {
96 write!(
97 f,
98 "Environment variable error for '{}': {}",
99 var_name, message
100 )
101 }
102 PdfConvertError::OperationError { context, message } => {
103 write!(f, "{}: {}", context, message)
104 }
105 }
106 }
107}
108
109impl std::error::Error for PdfConvertError {
110 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
111 match self {
112 PdfConvertError::IoError { source, .. } => Some(source),
113 PdfConvertError::ApiError { source, .. } => source
114 .as_ref()
115 .map(|e| e as &(dyn std::error::Error + 'static)),
116 _ => None,
117 }
118 }
119}
120
121pub type Result<T> = std::result::Result<T, PdfConvertError>;
122
123impl From<std::io::Error> for PdfConvertError {
124 fn from(err: std::io::Error) -> Self {
125 PdfConvertError::IoError {
126 context: "performing file operation".to_string(),
127 source: err,
128 }
129 }
130}
131
132impl From<reqwest::Error> for PdfConvertError {
133 fn from(err: reqwest::Error) -> Self {
134 let status_code = err.status().map(|s| s.as_u16());
135 let message = err.to_string();
136
137 PdfConvertError::ApiError {
138 status_code,
139 message,
140 source: Some(err),
141 }
142 }
143}
144
145impl From<serde_json::Error> for PdfConvertError {
146 fn from(err: serde_json::Error) -> Self {
147 PdfConvertError::ParseError {
148 target: "JSON".to_string(),
149 message: err.to_string(),
150 }
151 }
152}
153
154impl From<std::env::VarError> for PdfConvertError {
155 fn from(err: std::env::VarError) -> Self {
156 match err {
157 std::env::VarError::NotPresent => PdfConvertError::EnvError {
158 var_name: "unknown".to_string(),
159 message: "Environment variable not set".to_string(),
160 },
161 std::env::VarError::NotUnicode(_) => PdfConvertError::EnvError {
162 var_name: "unknown".to_string(),
163 message: "Environment variable contains invalid Unicode".to_string(),
164 },
165 }
166 }
167}
168
169impl PdfConvertError {
170 pub fn io_error(context: impl Into<String>, source: std::io::Error) -> Self {
171 PdfConvertError::IoError {
172 context: context.into(),
173 source,
174 }
175 }
176
177 pub fn api_error(status_code: Option<u16>, message: impl Into<String>) -> Self {
178 PdfConvertError::ApiError {
179 status_code,
180 message: message.into(),
181 source: None,
182 }
183 }
184
185 pub fn parse_error(target: impl Into<String>, message: impl Into<String>) -> Self {
186 PdfConvertError::ParseError {
187 target: target.into(),
188 message: message.into(),
189 }
190 }
191
192 #[allow(dead_code)]
193 pub fn validation_error(parameter: impl Into<String>, reason: impl Into<String>) -> Self {
194 PdfConvertError::ValidationError {
195 parameter: parameter.into(),
196 reason: reason.into(),
197 }
198 }
199
200 pub fn env_error(var_name: impl Into<String>, message: impl Into<String>) -> Self {
201 PdfConvertError::EnvError {
202 var_name: var_name.into(),
203 message: message.into(),
204 }
205 }
206
207 pub fn operation_error(context: impl Into<String>, message: impl Into<String>) -> Self {
208 PdfConvertError::OperationError {
209 context: context.into(),
210 message: message.into(),
211 }
212 }
213
214 pub fn api_task_failed(status: impl Into<String>, details: impl Into<String>) -> Self {
215 PdfConvertError::ApiError {
216 status_code: None,
217 message: format!(
218 "Task failed - Status: {}, Details: {}",
219 status.into(),
220 details.into()
221 ),
222 source: None,
223 }
224 }
225
226 pub fn status_code(&self) -> Option<u16> {
235 match self {
236 PdfConvertError::ApiError {
237 status_code: Some(code),
238 ..
239 } => Some(*code),
240 PdfConvertError::ApiError {
241 source: Some(source),
242 ..
243 } => source.status().map(|status| status.as_u16()),
244 PdfConvertError::ApiError { status_code, .. } => *status_code,
245 _ => None,
246 }
247 }
248
249 pub fn api_message(&self) -> Option<&str> {
254 match self {
255 PdfConvertError::ApiError { message, .. } => Some(message.as_str()),
256 _ => None,
257 }
258 }
259
260 pub fn reqwest_source(&self) -> Option<&reqwest::Error> {
265 match self {
266 PdfConvertError::ApiError { source, .. } => source.as_ref(),
267 _ => None,
268 }
269 }
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275
276 #[test]
277 fn api_error_status_code_accessor_matches_display_for_auth_and_server_errors() {
278 for status in [401u16, 403, 404, 429, 500, 502, 503] {
281 let message = format!("upstream failed with test status {status}");
282 let error = PdfConvertError::api_error(Some(status), message.clone());
283 assert_eq!(
284 error.status_code(),
285 Some(status),
286 "accessor must return structured status {status}"
287 );
288 assert_eq!(error.api_message(), Some(message.as_str()));
289 assert!(error.reqwest_source().is_none());
290 let display = error.to_string();
291 assert!(
292 display.contains(&format!("HTTP {status}")),
293 "Display must expose HTTP {status}: {display}"
294 );
295 assert!(
296 display.contains(&message),
297 "Display must keep message: {display}"
298 );
299 }
300 }
301
302 #[test]
303 fn uuid_containing_401_has_no_status_code_and_no_http_prefix() {
304 let uuid_message =
308 "failed to poll docling task 401de82e-e717-4f6a-9c2a-9b1a2c3d4e5f: dns error";
309 let error = PdfConvertError::api_error(None, uuid_message);
310 assert_eq!(error.status_code(), None);
311 assert_eq!(error.api_message(), Some(uuid_message));
312 let display = error.to_string();
313 assert_eq!(display, uuid_message);
314 assert!(
315 !display.contains("HTTP 401") && !display.contains("HTTP 403"),
316 "UUID Display must not fabricate an HTTP status: {display}"
317 );
318
319 let port_message = "http://192.168.67.31:5001/v1/status/poll unreachable";
320 let port_error = PdfConvertError::api_error(None, port_message);
321 assert_eq!(port_error.status_code(), None);
322 assert!(!port_error.to_string().contains("HTTP 5"));
323
324 let operation =
326 PdfConvertError::operation_error("poll 401de82e task", "dns error for task");
327 assert_eq!(operation.status_code(), None);
328 assert_eq!(operation.api_message(), None);
329 assert!(operation.reqwest_source().is_none());
330 assert!(!operation.to_string().contains("HTTP 401"));
331 }
332
333 async fn reqwest_error_with_status(status: u16) -> reqwest::Error {
334 use tokio::io::AsyncWriteExt;
335 use tokio::net::TcpListener;
336
337 let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
338 let addr = listener.local_addr().expect("addr");
339 let server = tokio::spawn(async move {
340 if let Ok((mut socket, _)) = listener.accept().await {
341 let reason = match status {
342 401 => "Unauthorized",
343 403 => "Forbidden",
344 503 => "Service Unavailable",
345 _ => "Error",
346 };
347 let response = format!(
348 "HTTP/1.1 {status} {reason}\r\ncontent-length: 0\r\nconnection: close\r\n\r\n"
349 );
350 let _ = socket.write_all(response.as_bytes()).await;
351 }
352 });
353 let url = format!("http://{addr}/");
354 let error = reqwest::Client::new()
355 .get(url)
356 .send()
357 .await
358 .expect("send")
359 .error_for_status()
360 .expect_err("expected HTTP error status");
361 let _ = server.await;
362 assert_eq!(error.status().map(|status| status.as_u16()), Some(status));
363 error
364 }
365
366 #[tokio::test]
367 async fn from_reqwest_preserves_status_and_display_exposes_http_code() {
368 for status in [401u16, 503] {
372 let reqwest_error = reqwest_error_with_status(status).await;
373 let error = PdfConvertError::from(reqwest_error);
374 assert_eq!(
375 error.status_code(),
376 Some(status),
377 "From<reqwest> must preserve HTTP {status}"
378 );
379 assert!(error.reqwest_source().is_some());
380 assert_eq!(
381 error
382 .reqwest_source()
383 .and_then(|source| source.status().map(|status| status.as_u16())),
384 Some(status)
385 );
386 let display = error.to_string();
387 assert!(
388 display.contains(&format!("HTTP {status}")),
389 "Display with source must still expose HTTP {status}: {display}"
390 );
391 }
392 }
393}