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