Skip to main content

pgsync_core/
error.rs

1//! Structured, categorised errors.
2//!
3//! Every runtime failure in PGSync carries a [`ErrorKind`] so the engine can decide
4//! whether to retry, stop, or demand operator action without string matching.
5
6use std::fmt;
7
8/// How the engine should react to an error.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize)]
10#[serde(rename_all = "snake_case")]
11pub enum ErrorKind {
12    /// Network, timeout, or server restart. Retry with backoff.
13    Transient,
14    /// Configuration problem. Stop; operator fixes config.
15    Config,
16    /// Source does not meet prerequisites (wal_level, privileges, missing publication...).
17    Preflight,
18    /// A change cannot be applied safely (unsupported type/operation, missing key, DDL).
19    /// Stop without acknowledging. Operator intervention required.
20    Unsupported,
21    /// Replication continuity cannot be proven (slot missing, WAL lost, identity changed).
22    /// Job must be resynchronised from a new snapshot.
23    ResyncRequired,
24    /// Memory or resource budget exceeded. Stop without acknowledging; may be recoverable
25    /// after raising limits.
26    Resource,
27    /// Source and target have diverged (e.g. an UPDATE matched no row). Stop.
28    Consistency,
29    /// Protocol violation or internal invariant failure. Stop.
30    Internal,
31    /// Operation cancelled by shutdown request.
32    Cancelled,
33}
34
35impl ErrorKind {
36    pub fn is_retryable(self) -> bool {
37        matches!(self, ErrorKind::Transient)
38    }
39}
40
41impl fmt::Display for ErrorKind {
42    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43        let s = match self {
44            ErrorKind::Transient => "transient",
45            ErrorKind::Config => "config",
46            ErrorKind::Preflight => "preflight",
47            ErrorKind::Unsupported => "unsupported",
48            ErrorKind::ResyncRequired => "resync_required",
49            ErrorKind::Resource => "resource",
50            ErrorKind::Consistency => "consistency",
51            ErrorKind::Internal => "internal",
52            ErrorKind::Cancelled => "cancelled",
53        };
54        f.write_str(s)
55    }
56}
57
58/// The PGSync error type.
59#[derive(Debug)]
60pub struct Error {
61    kind: ErrorKind,
62    message: String,
63    source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
64}
65
66impl Error {
67    pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
68        Error {
69            kind,
70            message: message.into(),
71            source: None,
72        }
73    }
74
75    pub fn with_source(
76        kind: ErrorKind,
77        message: impl Into<String>,
78        source: impl std::error::Error + Send + Sync + 'static,
79    ) -> Self {
80        Error {
81            kind,
82            message: message.into(),
83            source: Some(Box::new(source)),
84        }
85    }
86
87    pub fn transient(message: impl Into<String>) -> Self {
88        Self::new(ErrorKind::Transient, message)
89    }
90    pub fn config(message: impl Into<String>) -> Self {
91        Self::new(ErrorKind::Config, message)
92    }
93    pub fn preflight(message: impl Into<String>) -> Self {
94        Self::new(ErrorKind::Preflight, message)
95    }
96    pub fn unsupported(message: impl Into<String>) -> Self {
97        Self::new(ErrorKind::Unsupported, message)
98    }
99    pub fn resync_required(message: impl Into<String>) -> Self {
100        Self::new(ErrorKind::ResyncRequired, message)
101    }
102    pub fn resource(message: impl Into<String>) -> Self {
103        Self::new(ErrorKind::Resource, message)
104    }
105    pub fn internal(message: impl Into<String>) -> Self {
106        Self::new(ErrorKind::Internal, message)
107    }
108    pub fn consistency(message: impl Into<String>) -> Self {
109        Self::new(ErrorKind::Consistency, message)
110    }
111    pub fn cancelled() -> Self {
112        Self::new(ErrorKind::Cancelled, "operation cancelled")
113    }
114
115    pub fn kind(&self) -> ErrorKind {
116        self.kind
117    }
118
119    pub fn message(&self) -> &str {
120        &self.message
121    }
122
123    pub fn is_retryable(&self) -> bool {
124        self.kind.is_retryable()
125    }
126
127    /// Prefix the message with context, preserving kind and source.
128    pub fn context(mut self, ctx: impl fmt::Display) -> Self {
129        self.message = format!("{ctx}: {}", self.message);
130        self
131    }
132}
133
134impl fmt::Display for Error {
135    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136        write!(f, "[{}] {}", self.kind, self.message)?;
137        if let Some(src) = &self.source {
138            write!(f, ": {src}")?;
139        }
140        Ok(())
141    }
142}
143
144impl std::error::Error for Error {
145    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
146        self.source
147            .as_deref()
148            .map(|e| e as &(dyn std::error::Error + 'static))
149    }
150}
151
152pub type Result<T, E = Error> = std::result::Result<T, E>;
153
154/// Extension to attach context to results.
155pub trait ResultExt<T> {
156    fn context(self, ctx: impl fmt::Display) -> Result<T>;
157}
158
159impl<T> ResultExt<T> for Result<T> {
160    fn context(self, ctx: impl fmt::Display) -> Result<T> {
161        self.map_err(|e| e.context(ctx))
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn display_includes_kind_and_source() {
171        let io = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "reset by peer");
172        let e = Error::with_source(ErrorKind::Transient, "source connection lost", io);
173        assert_eq!(
174            e.to_string(),
175            "[transient] source connection lost: reset by peer"
176        );
177        assert!(e.is_retryable());
178        assert!(std::error::Error::source(&e).is_some());
179    }
180
181    #[test]
182    fn only_transient_is_retryable() {
183        assert!(Error::transient("x").is_retryable());
184        for e in [
185            Error::config("x"),
186            Error::preflight("x"),
187            Error::unsupported("x"),
188            Error::resync_required("x"),
189            Error::resource("x"),
190            Error::internal("x"),
191            Error::consistency("x"),
192            Error::cancelled(),
193        ] {
194            assert!(!e.is_retryable(), "{e}");
195        }
196    }
197
198    #[test]
199    fn context_prefixes_message_and_keeps_kind() {
200        let e = Error::unsupported("no replica identity").context("table public.t");
201        assert_eq!(e.kind(), ErrorKind::Unsupported);
202        assert_eq!(e.message(), "table public.t: no replica identity");
203    }
204}