1use std::time::Duration;
4use thiserror::Error;
5
6#[derive(Debug, Clone, Default, PartialEq, Eq)]
8pub struct ViolationFacts {
9 pub label: Option<String>,
11 pub sink: Option<String>,
13 pub sink_kind: Option<String>,
15}
16
17#[derive(Debug, Error)]
25#[non_exhaustive]
26pub enum FaucetError {
27 #[error("HTTP error: {0}")]
28 Http(#[from] reqwest::Error),
29
30 #[error("HTTP {status} from {url}: {body}")]
36 HttpStatus {
37 status: u16,
38 url: String,
39 body: String,
40 },
41
42 #[error("JSON error: {0}")]
43 Json(#[from] serde_json::Error),
44
45 #[error("JSONPath error: {0}")]
46 JsonPath(String),
47
48 #[error("Auth error: {0}")]
49 Auth(String),
50
51 #[error("Rate limited: retry after {0:?}")]
55 RateLimited(Duration),
56
57 #[error("URL error: {0}")]
59 Url(String),
60
61 #[error("Transform error: {0}")]
63 Transform(String),
64
65 #[error("Config error: {0}")]
67 Config(String),
68
69 #[error("Source error: {0}")]
71 Source(String),
72
73 #[error("Sink error: {0}")]
75 Sink(String),
76
77 #[error("Quality check '{check}' failed: {message}")]
79 QualityFailure { check: String, message: String },
80
81 #[error("Schema drift on columns {columns:?}: {message}")]
84 SchemaDrift {
85 columns: Vec<String>,
86 message: String,
87 },
88
89 #[error("Policy `{rule}` violated on column `{column}`: {message}")]
93 PolicyViolation {
94 rule: String,
95 column: String,
96 message: String,
97 facts: Option<Box<ViolationFacts>>,
100 },
101
102 #[error("Budget `{budget}` exceeded: limit {limit}, actual {actual}")]
109 BudgetExceeded {
110 budget: String,
111 limit: u64,
112 actual: u64,
113 },
114
115 #[error(
122 "Rows unaccounted: the sink accepted {accepted} of {offered} offered rows and reported no per-row failure or coalescing for the rest"
123 )]
124 RowsUnaccounted { offered: u64, accepted: u64 },
125
126 #[error("Profile drift on columns {columns:?}: {message}")]
130 ProfileDrift {
131 columns: Vec<String>,
132 message: String,
133 },
134
135 #[error("Contract v{version} violated: {message}")]
139 ContractViolation { version: String, message: String },
140
141 #[error("State error: {0}")]
144 State(String),
145
146 #[error(
151 "state '{key}' is incompatible: found {found}, expected {expected} — it was written by a \
152 newer faucet or by a different source; run the release that wrote it, or reset the row \
153 (`faucet state reset`) after confirming where it should resume"
154 )]
155 StateIncompatible {
156 key: String,
158 found: String,
160 expected: String,
162 },
163
164 #[error("Circuit open after {failures} consecutive failures; cooldown {cooldown:?}")]
168 CircuitOpen { failures: u32, cooldown: Duration },
169
170 #[error("{operation} timed out after {after:?}")]
175 Timeout {
176 operation: String,
178 after: Duration,
180 },
181
182 #[error("{operation} cancelled")]
186 Cancelled {
187 operation: String,
189 },
190
191 #[error("Connector error: {0}")]
202 Custom(#[from] Box<dyn std::error::Error + Send + Sync>),
203}
204
205impl FaucetError {
206 pub fn sink_status(status: Option<u16>, url: impl Into<String>, message: String) -> Self {
212 match status {
213 Some(status) if status == 429 || status >= 500 => FaucetError::HttpStatus {
214 status,
215 url: url.into(),
216 body: message,
217 },
218 _ => FaucetError::Sink(message),
219 }
220 }
221
222 pub fn is_retriable(&self) -> bool {
233 match self {
234 FaucetError::Http(e) => {
236 if let Some(status) = e.status() {
238 status.is_server_error()
239 } else {
240 true
242 }
243 }
244 FaucetError::HttpStatus { status, .. } => *status >= 500 || *status == 429,
249 FaucetError::RateLimited(_) => true,
250 FaucetError::Timeout { .. } => true,
251 _ => false,
252 }
253 }
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259
260 #[test]
261 fn rows_unaccounted_message_reads_as_one_sentence() {
262 let msg = FaucetError::RowsUnaccounted {
263 offered: 10,
264 accepted: 9,
265 }
266 .to_string();
267 assert!(!msg.contains(" "), "{msg}");
268 assert!(msg.contains("accepted 9 of 10 offered rows and reported no per-row failure"));
269 }
270
271 #[test]
272 fn sink_status_types_only_retryable_statuses() {
273 for status in [429, 500, 503] {
274 let e = FaucetError::sink_status(Some(status), "s3://b/k", "m".into());
275 assert!(
276 matches!(e, FaucetError::HttpStatus { status: s, ref url, ref body }
277 if s == status && url == "s3://b/k" && body == "m")
278 );
279 assert!(e.is_retriable());
280 }
281 for status in [None, Some(403), Some(404)] {
282 let e = FaucetError::sink_status(status, "u", "m".into());
283 assert!(matches!(e, FaucetError::Sink(ref m) if m == "m"), "{e:?}");
284 }
285 }
286
287 #[test]
288 fn http_status_5xx_is_retriable() {
289 let err = FaucetError::HttpStatus {
290 status: 500,
291 url: "https://example.com".into(),
292 body: "Internal Server Error".into(),
293 };
294 assert!(err.is_retriable());
295
296 let err = FaucetError::HttpStatus {
297 status: 503,
298 url: "https://example.com".into(),
299 body: "".into(),
300 };
301 assert!(err.is_retriable());
302 }
303
304 #[test]
305 fn http_status_4xx_is_not_retriable() {
306 let err = FaucetError::HttpStatus {
307 status: 400,
308 url: "https://example.com".into(),
309 body: "Bad Request".into(),
310 };
311 assert!(!err.is_retriable());
312
313 let err = FaucetError::HttpStatus {
314 status: 404,
315 url: "https://example.com".into(),
316 body: "".into(),
317 };
318 assert!(!err.is_retriable());
319 }
320
321 #[test]
322 fn http_status_429_is_retriable() {
323 let err = FaucetError::HttpStatus {
326 status: 429,
327 url: "https://example.com".into(),
328 body: "Too Many Requests".into(),
329 };
330 assert!(err.is_retriable());
331 }
332
333 #[test]
334 fn cancelled_is_not_retriable() {
335 let err = FaucetError::Cancelled {
336 operation: "page call".into(),
337 };
338 assert!(!err.is_retriable());
339 assert_eq!(err.to_string(), "page call cancelled");
340 }
341
342 #[test]
343 fn timeout_is_retriable_and_names_the_operation() {
344 let err = FaucetError::Timeout {
345 operation: "nats publish".into(),
346 after: Duration::from_secs(30),
347 };
348 assert!(err.is_retriable());
349 assert_eq!(err.to_string(), "nats publish timed out after 30s");
350 }
351
352 #[test]
353 fn rate_limited_is_retriable() {
354 let err = FaucetError::RateLimited(Duration::from_secs(30));
355 assert!(err.is_retriable());
356 }
357
358 #[test]
359 fn json_error_is_not_retriable() {
360 let serde_err = serde_json::from_str::<serde_json::Value>("not json").unwrap_err();
361 let err = FaucetError::Json(serde_err);
362 assert!(!err.is_retriable());
363 }
364
365 #[test]
366 fn jsonpath_error_is_not_retriable() {
367 let err = FaucetError::JsonPath("bad path".into());
368 assert!(!err.is_retriable());
369 }
370
371 #[test]
372 fn auth_error_is_not_retriable() {
373 let err = FaucetError::Auth("invalid token".into());
374 assert!(!err.is_retriable());
375 }
376
377 #[test]
378 fn url_error_is_not_retriable() {
379 let err = FaucetError::Url("bad url".into());
380 assert!(!err.is_retriable());
381 }
382
383 #[test]
384 fn transform_error_is_not_retriable() {
385 let err = FaucetError::Transform("bad regex".into());
386 assert!(!err.is_retriable());
387 }
388
389 #[test]
390 fn http_status_display_includes_url_and_body() {
391 let err = FaucetError::HttpStatus {
392 status: 422,
393 url: "https://api.example.com/test".into(),
394 body: "Unprocessable Entity".into(),
395 };
396 let msg = err.to_string();
397 assert!(msg.contains("422"));
398 assert!(msg.contains("https://api.example.com/test"));
399 assert!(msg.contains("Unprocessable Entity"));
400 }
401
402 #[test]
403 fn config_error_is_not_retriable() {
404 let err = FaucetError::Config("bad endpoint".into());
405 assert!(!err.is_retriable());
406 }
407
408 #[test]
409 fn config_error_display() {
410 let err = FaucetError::Config("missing descriptor".into());
411 assert_eq!(err.to_string(), "Config error: missing descriptor");
412 }
413
414 #[test]
415 fn source_error_is_not_retriable() {
416 let err = FaucetError::Source("query failed".into());
417 assert!(!err.is_retriable());
418 }
419
420 #[test]
421 fn source_error_display() {
422 let err = FaucetError::Source("connection refused".into());
423 assert_eq!(err.to_string(), "Source error: connection refused");
424 }
425
426 #[test]
427 fn custom_error_is_not_retriable() {
428 let err = FaucetError::Custom(Box::new(std::io::Error::other("custom failure")));
429 assert!(!err.is_retriable());
430 }
431
432 #[test]
433 fn custom_error_display() {
434 let err = FaucetError::Custom(Box::new(std::io::Error::other("custom failure")));
435 assert_eq!(err.to_string(), "Connector error: custom failure");
436 }
437
438 #[test]
439 fn custom_error_from_boxed() {
440 let io_err = std::io::Error::other("file missing");
441 let boxed: Box<dyn std::error::Error + Send + Sync> = Box::new(io_err);
442 let err: FaucetError = boxed.into();
443 assert!(matches!(err, FaucetError::Custom(_)));
444 }
445
446 #[test]
447 fn sink_error_is_not_retriable() {
448 let err = FaucetError::Sink("BigQuery insert failed".into());
449 assert!(!err.is_retriable());
450 }
451
452 #[test]
453 fn sink_error_display() {
454 let err = FaucetError::Sink("connection refused".into());
455 assert_eq!(err.to_string(), "Sink error: connection refused");
456 }
457
458 #[test]
459 fn quality_failure_is_not_retriable_and_displays() {
460 let err = FaucetError::QualityFailure {
461 check: "not_null".into(),
462 message: "field 'user_id' was null".into(),
463 };
464 assert!(!err.is_retriable());
465 let s = err.to_string();
466 assert!(s.contains("not_null"));
467 assert!(s.contains("user_id"));
468 }
469
470 #[test]
471 fn schema_drift_is_not_retriable_and_displays() {
472 let err = FaucetError::SchemaDrift {
473 columns: vec!["email".into(), "score".into()],
474 message: "2 new columns".into(),
475 };
476 assert!(!err.is_retriable());
477 let s = err.to_string();
478 assert!(s.contains("email"));
479 assert!(s.contains("2 new columns"));
480 }
481
482 #[test]
483 fn circuit_open_is_not_retriable_and_displays() {
484 let err = FaucetError::CircuitOpen {
485 failures: 5,
486 cooldown: std::time::Duration::from_secs(60),
487 };
488 assert!(!err.is_retriable());
489 let s = err.to_string();
490 assert!(s.contains("5"));
491 assert!(s.contains("Circuit open"));
492 }
493}