1use serde::ser::SerializeStruct;
4use serde::{Deserialize, Serialize, Serializer};
5use std::borrow::Cow;
6use std::fmt;
7use std::sync::Arc;
8
9pub struct Error {
11 kind: ErrorKind,
12 message: Cow<'static, str>,
13 retryable: bool,
14 source: Option<Box<dyn std::error::Error + Send + Sync>>,
15}
16
17#[derive(Clone, Debug, PartialEq, Eq)]
19#[non_exhaustive]
20pub enum ErrorKind {
21 Config,
23 Compile,
25 Eval,
27 Budget,
29 TimedOut,
31 Cancelled,
33 Service {
35 code: Cow<'static, str>,
37 },
38}
39
40pub(crate) const TASK_ERROR: &str = "TASK_ERROR";
43pub(crate) const TASK_STATUS_ERROR: &str = "TASK_STATUS_ERROR";
45pub(crate) const VALIDATION_ERROR: &str = "VALIDATION_ERROR";
47pub(crate) const MAPPING_ERROR: &str = "MAPPING_ERROR";
50pub(crate) const EVALUATION_ERROR: &str = "EVALUATION_ERROR";
53
54impl Error {
55 pub(crate) fn new(kind: ErrorKind, message: impl Into<Cow<'static, str>>) -> Self {
56 Self {
57 retryable: kind == ErrorKind::TimedOut,
58 kind,
59 message: message.into(),
60 source: None,
61 }
62 }
63
64 pub fn service(
66 code: impl Into<Cow<'static, str>>,
67 message: impl Into<Cow<'static, str>>,
68 ) -> Self {
69 Self {
70 kind: ErrorKind::Service { code: code.into() },
71 message: message.into(),
72 retryable: false,
73 source: None,
74 }
75 }
76
77 pub fn kind(&self) -> &ErrorKind {
79 &self.kind
80 }
81
82 pub fn message(&self) -> &str {
84 &self.message
85 }
86
87 #[must_use]
89 pub fn with_retryable(mut self, yes: bool) -> Self {
90 self.retryable = yes;
91 self
92 }
93
94 #[must_use]
96 pub fn with_source(mut self, source: impl std::error::Error + Send + Sync + 'static) -> Self {
97 self.source = Some(Box::new(source));
98 self
99 }
100
101 pub fn is_retryable(&self) -> bool {
104 self.retryable
105 }
106
107 #[must_use]
109 pub fn wrap(mut self, context: impl fmt::Display) -> Self {
110 self.message = Cow::Owned(format!("{context}: {}", self.message));
111 self
112 }
113
114 pub fn code(&self) -> &str {
116 match &self.kind {
117 ErrorKind::Service { code } if !code.is_empty() => code,
118 _ => self.code_static(),
119 }
120 }
121
122 pub(crate) fn code_static(&self) -> &'static str {
125 match &self.kind {
126 ErrorKind::Eval => "LOGIC_ERROR",
127 ErrorKind::Budget => "BUDGET_EXCEEDED",
128 ErrorKind::TimedOut => "TIMEOUT_ERROR",
129 ErrorKind::Cancelled => "CANCELLED",
130 ErrorKind::Config => "CONFIG_ERROR",
131 ErrorKind::Compile => "COMPILATION_ERROR",
132 ErrorKind::Service { .. } => TASK_ERROR,
133 }
134 }
135
136 pub(crate) fn record_text(&self) -> Cow<'_, str> {
141 let prefix = match &self.kind {
142 ErrorKind::Eval => "Logic evaluation error: ",
143 ErrorKind::TimedOut => "Timeout error: ",
144 ErrorKind::Budget
145 | ErrorKind::Config
146 | ErrorKind::Compile
147 | ErrorKind::Cancelled
148 | ErrorKind::Service { .. } => return Cow::Borrowed(&self.message),
149 };
150 Cow::Owned(format!("{prefix}{}", self.message))
151 }
152}
153
154impl fmt::Display for Error {
155 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156 f.write_str(&self.message)
157 }
158}
159
160impl fmt::Debug for Error {
161 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
162 f.debug_struct("Error")
163 .field("kind", &self.kind)
164 .field("message", &self.message)
165 .field("retryable", &self.is_retryable())
166 .field("source", &self.source)
167 .finish_non_exhaustive()
168 }
169}
170
171impl std::error::Error for Error {
172 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
173 self.source.as_deref().map(|s| s as _)
174 }
175}
176
177pub type Result<T> = std::result::Result<T, Error>;
179
180#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
186#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
187#[non_exhaustive]
188pub struct ErrorRecord {
189 pub code: String,
191 pub message: String,
193 pub path: Option<String>,
195 #[serde(skip_serializing_if = "Option::is_none")]
198 #[cfg_attr(feature = "ts", ts(optional))]
199 pub workflow_id: Option<Arc<str>>,
200 #[serde(skip_serializing_if = "Option::is_none")]
202 #[cfg_attr(feature = "ts", ts(optional))]
203 pub task_id: Option<Arc<str>>,
204 #[serde(skip_serializing_if = "Option::is_none")]
206 #[cfg_attr(feature = "ts", ts(optional))]
207 pub timestamp: Option<Arc<str>>,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
210 #[cfg_attr(feature = "ts", ts(optional))]
211 pub detail: Option<String>,
212 #[serde(default, skip_serializing_if = "Option::is_none")]
214 #[cfg_attr(feature = "ts", ts(optional))]
215 pub element_index: Option<usize>,
216}
217
218impl ErrorRecord {
219 pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
221 Self {
222 code: code.into(),
223 message: message.into(),
224 path: None,
225 workflow_id: None,
226 task_id: None,
227 timestamp: None,
228 detail: None,
229 element_index: None,
230 }
231 }
232
233 pub(crate) fn stamp(
240 &mut self,
241 workflow_id: &Arc<str>,
242 task_id: Option<&Arc<str>>,
243 now: &Arc<str>,
244 element_index: Option<usize>,
245 ) {
246 if self.workflow_id.is_none() {
247 self.workflow_id = Some(Arc::clone(workflow_id));
248 }
249 if self.task_id.is_none() {
250 self.task_id = task_id.map(Arc::clone);
251 }
252 if self.timestamp.is_none() {
253 self.timestamp = Some(Arc::clone(now));
254 }
255 if self.element_index.is_none() {
256 self.element_index = element_index;
257 }
258 }
259}
260
261impl From<&Error> for ErrorRecord {
262 fn from(error: &Error) -> Self {
263 Self::new(error.code(), error.message())
264 }
265}
266
267impl Serialize for Error {
270 fn serialize<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
271 let mut o = s.serialize_struct("Error", 3)?;
272 o.serialize_field("code", self.code())?;
273 o.serialize_field("message", self.message())?;
274 o.serialize_field("retryable", &self.is_retryable())?;
275 o.end()
276 }
277}
278
279#[cfg(feature = "ts")]
282crate::base::ts::ts_decl!(
283 Error,
284 "DataflowError",
285 [
286 " An error as JSON: its code, its message, and whether a retry may",
287 " succeed.",
288 ],
289 |_cfg| {
290 use crate::base::ts::{Prop, object};
291 object(&[
292 Prop::new("code", "string", &[" The recorded code."]),
293 Prop::new("message", "string", &[" The human-readable message."]),
294 Prop::new("retryable", "boolean", &[" May a retry succeed?"]),
295 ])
296 }
297);
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302
303 #[test]
304 fn record_text_prefixes_two_kinds_and_is_the_message_otherwise() {
305 let m = "the message";
307 for (kind, expected) in [
308 (ErrorKind::Eval, "Logic evaluation error: the message"),
309 (ErrorKind::TimedOut, "Timeout error: the message"),
310 (ErrorKind::Budget, m),
311 (ErrorKind::Service { code: "X".into() }, m),
312 (ErrorKind::Config, m),
313 (ErrorKind::Compile, m),
314 (ErrorKind::Cancelled, m),
315 ] {
316 assert_eq!(
317 Error::new(kind.clone(), m).record_text(),
318 expected,
319 "{kind:?}"
320 );
321 }
322 }
323
324 fn kinds() -> Vec<(ErrorKind, &'static str)> {
325 vec![
326 (ErrorKind::Eval, "LOGIC_ERROR"),
327 (ErrorKind::Budget, "BUDGET_EXCEEDED"),
328 (ErrorKind::TimedOut, "TIMEOUT_ERROR"),
329 (ErrorKind::Cancelled, "CANCELLED"),
330 (ErrorKind::Config, "CONFIG_ERROR"),
331 (ErrorKind::Compile, "COMPILATION_ERROR"),
332 (
333 ErrorKind::Service {
334 code: "X_FAIL".into(),
335 },
336 "X_FAIL",
337 ),
338 (ErrorKind::Service { code: "".into() }, "TASK_ERROR"),
339 ]
340 }
341
342 #[test]
343 fn code_gives_each_row_of_the_table() {
344 for (kind, code) in kinds() {
345 assert_eq!(Error::new(kind, "m").code(), code);
346 }
347 }
348
349 #[test]
350 fn only_a_timeout_is_retryable_by_default() {
351 for (kind, _) in kinds() {
352 let timed_out = kind == ErrorKind::TimedOut;
353 assert_eq!(Error::new(kind, "m").is_retryable(), timed_out);
354 }
355 assert!(Error::service("A", "m").with_retryable(true).is_retryable());
356 assert!(
357 !Error::new(ErrorKind::TimedOut, "m")
358 .with_retryable(false)
359 .is_retryable()
360 );
361 }
362
363 #[test]
364 fn wrap_keeps_kind_retryable_and_source() {
365 let source = std::io::Error::other("disk");
366 let error = Error::new(ErrorKind::Eval, "bad")
367 .with_retryable(true)
368 .with_source(source)
369 .wrap("task t");
370 assert_eq!(error.to_string(), "task t: bad");
371 assert_eq!(error.kind(), &ErrorKind::Eval);
372 assert!(error.is_retryable());
373 let source = std::error::Error::source(&error).expect("source kept");
374 assert_eq!(source.to_string(), "disk");
375 }
376
377 #[test]
378 fn debug_shows_kind_message_and_retryable() {
379 let text = format!("{:?}", Error::new(ErrorKind::Budget, "over"));
380 assert!(text.contains("Budget") && text.contains("over") && text.contains("retryable"));
381 }
382
383 #[test]
384 fn a_stored_record_with_3x_retry_keys_reads_and_writes_without_them() {
385 let stored =
386 r#"{"code":"X","message":"m","path":null,"retry_attempted":true,"retry_count":2}"#;
387 let record: ErrorRecord = serde_json::from_str(stored).unwrap();
388 assert_eq!(
389 serde_json::to_string(&record).unwrap(),
390 r#"{"code":"X","message":"m","path":null}"#
391 );
392 }
393
394 #[test]
395 fn stamp_fills_only_what_the_record_leaves_unset() {
396 let mut record = ErrorRecord::new("X", "m");
397 record.task_id = Some(Arc::from("own"));
398 record.stamp(
399 &Arc::from("w"),
400 Some(&Arc::from("t")),
401 &Arc::from("now"),
402 Some(3),
403 );
404 assert_eq!(
405 serde_json::to_string(&record).unwrap(),
406 r#"{"code":"X","message":"m","path":null,"workflow_id":"w","task_id":"own","timestamp":"now","element_index":3}"#
407 );
408 let mut workflow_own = ErrorRecord::new("X", "m");
409 workflow_own.stamp(&Arc::from("w"), None, &Arc::from("now"), None);
410 assert_eq!(workflow_own.task_id, None);
411 }
412
413 #[test]
414 fn a_record_from_an_error_carries_only_code_and_message() {
415 let record = ErrorRecord::from(&Error::service("X", "boom"));
416 assert_eq!(
417 serde_json::to_string(&record).unwrap(),
418 r#"{"code":"X","message":"boom","path":null}"#
419 );
420 }
421}