Skip to main content

cloudiful_docling_convert/
error.rs

1use 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                    // Robust Display with compatibility (issue #176): the
58                    // `From<reqwest::Error>` path used to hide the HTTP status
59                    // behind the reqwest message, forcing callers to parse
60                    // `Display` for `HTTP XXX`. Expose the effective status
61                    // when known while keeping the original reqwest message
62                    // (and its cause chain) intact so existing log matching
63                    // keeps working; callers should prefer `status_code()`.
64                    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                    // Preserve a divergent stored message (normally equal to
77                    // `src.to_string()` via `From<reqwest::Error>`) without
78                    // breaking the `HTTP {code}: {src}` prefix contract.
79                    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    /// Structured HTTP status for [`PdfConvertError::ApiError`] (issue #176).
227    ///
228    /// Prefers the stored `status_code` (populated by `api_error`,
229    /// `handle_response`, and `From<reqwest::Error>`), then falls back to the
230    /// wrapped `reqwest::Error::status()`. Returns `None` for non-API variants
231    /// and for transport errors without an HTTP status (DNS, connect,
232    /// timeout). UUID hex fragments such as `401de82e` never yield a status;
233    /// only a real HTTP status counts.
234    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    /// Borrowed message for [`PdfConvertError::ApiError`], if present.
250    ///
251    /// Lets callers read the human-readable detail without parsing
252    /// `Display`; returns `None` for non-API variants.
253    pub fn api_message(&self) -> Option<&str> {
254        match self {
255            PdfConvertError::ApiError { message, .. } => Some(message.as_str()),
256            _ => None,
257        }
258    }
259
260    /// Typed `reqwest` cause for [`PdfConvertError::ApiError`], if present.
261    ///
262    /// Useful when callers need more than the status (e.g. timeout vs.
263    /// status errors) without downcasting via [`std::error::Error::source`].
264    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        // True HTTP statuses must round-trip through the structured accessor
279        // and stay visible in `Display` (issue #176).
280        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        // UUID hex fragments such as `401de82e` must not be mistaken for a
305        // standalone HTTP 401/403, and a `:5001` port must not count as 5xx.
306        // Both the accessor and `Display` must stay consistent (issue #176).
307        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        // Non-API variants never carry a status either.
325        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        // The `From<reqwest::Error>` path must retain the HTTP status in the
369        // structured accessor even though `Display` previously hid it behind
370        // the reqwest message (issue #176).
371        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}