1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
//! Error type for conversion.
use std::fmt;
use crate::format::InputFormat;
/// Anything that can go wrong while loading or converting a source document.
#[derive(Debug)]
pub enum ConversionError {
/// Reading the input from disk failed.
Io(std::io::Error),
/// The file extension (or content) did not map to a known format.
UnknownFormat { hint: String },
/// The format is known but no backend is wired up for it yet.
UnsupportedFormat(InputFormat),
/// The backend recognized the format but failed to parse the content.
Parse(String),
/// The requested streaming conversion is not supported (e.g. JSON, or the
/// referenced image mode, which both need the whole document up front).
Streaming(String),
/// The headless-browser pre-render (`--use-web-browser`) failed, or the crate
/// was built without the `web-browser` feature.
Browser(String),
/// A conversion worker panicked — a backend bug reached on some input
/// (#395/#396). The panic itself is not swallowed: it still unwinds its own
/// thread and prints its message and backtrace to stderr. This turns it
/// into an ordinary error for the caller, so a server answers with a 500
/// instead of a silent empty document and a batch keeps going.
Panic(String),
/// The document budget (`DocumentConverter::document_timeout`, #497) ran
/// out on a **streaming** conversion. Not a failure: every chunk emitted
/// before it is the partial document, and this is the stream's last item
/// — the streaming counterpart of [`crate::ConversionStatus::PartialSuccess`]
/// with a timeout [`crate::ErrorItem`]. Buffered conversions never raise
/// it; they report the cut on the result.
Timeout(String),
/// A dependency failed during conversion. Unlike [`ConversionError::Parse`]
/// the underlying error is kept alive (not flattened into a string), so
/// callers can walk [`std::error::Error::source`] and downcast to the
/// original type — e.g. to tell a truncated archive from malformed XML.
WithSource {
/// What was being converted when the failure happened (backend prefix,
/// mirroring the `Parse` message style: "xlsx", "docling-json", …).
context: String,
/// The error that caused the failure, preserved on the chain.
source: Box<dyn std::error::Error + Send + Sync>,
},
}
impl ConversionError {
/// Wrap a dependency error, keeping it reachable via
/// [`std::error::Error::source`] instead of stringifying it.
pub fn with_source(
context: impl Into<String>,
source: impl Into<Box<dyn std::error::Error + Send + Sync>>,
) -> Self {
ConversionError::WithSource {
context: context.into(),
source: source.into(),
}
}
}
impl fmt::Display for ConversionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ConversionError::Io(e) => write!(f, "i/o error: {e}"),
ConversionError::UnknownFormat { hint } => {
write!(f, "could not determine input format (hint: {hint})")
}
ConversionError::UnsupportedFormat(fmt) => {
write!(
f,
"no backend implemented yet for format '{}'",
fmt.as_str()
)
}
ConversionError::Parse(msg) => write!(f, "parse error: {msg}"),
ConversionError::Streaming(msg) => write!(f, "streaming not supported: {msg}"),
ConversionError::Browser(msg) => write!(f, "web-browser render error: {msg}"),
ConversionError::Panic(msg) => write!(f, "conversion panicked: {msg}"),
ConversionError::Timeout(msg) => write!(f, "document timeout: {msg}"),
ConversionError::WithSource { context, source } => {
// A source that already names its format (`PdfError`'s
// `pdf: …`) is not prefixed twice (`pdf: pdf: …`).
let msg = source.to_string();
if msg.starts_with(&format!("{context}: ")) {
write!(f, "parse error: {msg}")
} else {
write!(f, "parse error: {context}: {msg}")
}
}
}
}
}
impl std::error::Error for ConversionError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
ConversionError::Io(e) => Some(e),
ConversionError::WithSource { source, .. } => Some(source.as_ref()),
_ => None,
}
}
}
impl From<std::io::Error> for ConversionError {
fn from(e: std::io::Error) -> Self {
ConversionError::Io(e)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::error::Error;
#[test]
fn with_source_keeps_the_cause_on_the_chain() {
let cause = serde_json::from_str::<i32>("boom").unwrap_err();
let err = ConversionError::with_source("docling-json", cause);
assert!(err.to_string().starts_with("parse error: docling-json: "));
let source = err.source().expect("source is chained");
assert!(
source.downcast_ref::<serde_json::Error>().is_some(),
"chained source downcasts to the original type"
);
}
/// A source whose message already names the context is not prefixed
/// with it again (`parse error: pdf: pdf: …`).
#[test]
fn with_source_does_not_repeat_the_context() {
let cause = std::io::Error::other("pdf: not a readable PDF");
assert_eq!(
ConversionError::with_source("pdf", cause).to_string(),
"parse error: pdf: not a readable PDF"
);
let cause = std::io::Error::other("bad header");
assert_eq!(
ConversionError::with_source("pdf", cause).to_string(),
"parse error: pdf: bad header"
);
}
#[test]
fn stringly_variants_have_no_source() {
assert!(ConversionError::Parse("x".into()).source().is_none());
}
}