Skip to main content

fraiseql_wire/
error.rs

1//! Error types for fraiseql-wire
2
3use std::io;
4use thiserror::Error;
5
6/// Main error type for fraiseql-wire operations
7#[derive(Debug, Error)]
8pub enum Error {
9    /// Connection error
10    #[error("connection error: {0}")]
11    Connection(String),
12
13    /// Authentication error
14    #[error("authentication failed: {0}")]
15    Authentication(String),
16
17    /// Protocol violation
18    #[error("protocol error: {0}")]
19    Protocol(String),
20
21    /// SQL execution error
22    #[error("sql error: {0}")]
23    Sql(String),
24
25    /// JSON decoding error
26    #[error("json decode error: {0}")]
27    JsonDecode(#[from] serde_json::Error),
28
29    /// I/O error
30    #[error("io error: {0}")]
31    Io(#[from] io::Error),
32
33    /// Invalid configuration
34    #[error("invalid configuration: {0}")]
35    Config(String),
36
37    /// Query cancelled by client
38    #[error("query cancelled")]
39    Cancelled,
40
41    /// Invalid result schema (not single `data` column)
42    #[error("invalid result schema: {0}")]
43    InvalidSchema(String),
44
45    /// Connection already in use
46    #[error("connection busy: {0}")]
47    ConnectionBusy(String),
48
49    /// Invalid connection state
50    #[error("invalid connection state: expected {expected}, got {actual}")]
51    InvalidState {
52        /// Expected state
53        expected: String,
54        /// Actual state
55        actual: String,
56    },
57
58    /// Connection closed
59    #[error("connection closed")]
60    ConnectionClosed,
61
62    /// Type deserialization error
63    ///
64    /// Occurs when a row cannot be deserialized into the target type.
65    /// This is a consumer-side error that includes the type name and serde details.
66    #[error("deserialization error for type '{type_name}': {details}")]
67    Deserialization {
68        /// Name of the type we were deserializing to
69        type_name: String,
70        /// Details from `serde_json` about what went wrong
71        details: String,
72    },
73
74    /// Memory limit exceeded
75    ///
76    /// **Terminal error**: The consumer cannot keep pace with data arrival.
77    ///
78    /// Occurs when estimated buffered memory exceeds the configured maximum.
79    /// This indicates the consumer is too slow relative to data arrival rate.
80    ///
81    /// NOT retriable: Retrying the same query with the same consumer will hit the same limit.
82    ///
83    /// Solutions:
84    /// 1. Increase consumer throughput (faster `.next()` polling)
85    /// 2. Reduce items in flight (configure lower `chunk_size`)
86    /// 3. Remove memory limit (use unbounded mode)
87    /// 4. Use different transport (consider `tokio-postgres` for flexibility)
88    #[error("memory limit exceeded: {estimated_memory} bytes buffered > {limit} bytes limit")]
89    MemoryLimitExceeded {
90        /// Configured memory limit in bytes
91        limit: usize,
92        /// Current estimated memory in bytes (`items_buffered` * 2048)
93        estimated_memory: usize,
94    },
95}
96
97/// Result type alias using fraiseql-wire Error
98pub type Result<T> = std::result::Result<T, Error>;
99
100impl Error {
101    /// Create a connection error with context
102    pub fn connection<S: Into<String>>(msg: S) -> Self {
103        Error::Connection(msg.into())
104    }
105
106    /// Create a connection refused error (helpful message for debugging)
107    pub fn connection_refused(host: &str, port: u16) -> Self {
108        Error::Connection(format!(
109            "failed to connect to {}:{}: connection refused. \
110            Is Postgres running? Verify with: pg_isready -h {} -p {}",
111            host, port, host, port
112        ))
113    }
114
115    /// Create a protocol error with context
116    pub fn protocol<S: Into<String>>(msg: S) -> Self {
117        Error::Protocol(msg.into())
118    }
119
120    /// Create a SQL error with context
121    pub fn sql<S: Into<String>>(msg: S) -> Self {
122        Error::Sql(msg.into())
123    }
124
125    /// Create a schema validation error (query returned wrong columns)
126    pub fn invalid_schema_columns(num_columns: usize) -> Self {
127        Error::InvalidSchema(format!(
128            "query returned {} columns instead of 1. \
129            fraiseql-wire supports only: SELECT data FROM <view>. \
130            See troubleshooting.md#error-invalid-result-schema",
131            num_columns
132        ))
133    }
134
135    /// Create an invalid schema error with context
136    pub fn invalid_schema<S: Into<String>>(msg: S) -> Self {
137        Error::InvalidSchema(msg.into())
138    }
139
140    /// Create an authentication error with helpful message
141    pub fn auth_failed(username: &str, reason: &str) -> Self {
142        Error::Authentication(format!(
143            "authentication failed for user '{}': {}. \
144            Verify credentials with: psql -U {} -W",
145            username, reason, username
146        ))
147    }
148
149    /// Create a config error with helpful message
150    pub fn config_invalid<S: Into<String>>(msg: S) -> Self {
151        Error::Config(format!(
152            "invalid configuration: {}. \
153            Expected format: postgres://[user[:password]@][host[:port]]/[database]",
154            msg.into()
155        ))
156    }
157
158    /// Check if error is retriable (transient)
159    ///
160    /// Retriable errors typically indicate temporary issues that may succeed on retry:
161    /// - I/O errors (network timeouts, etc.)
162    /// - Connection closed (can reconnect)
163    ///
164    /// Non-retriable errors indicate permanent problems:
165    /// - Invalid schema (won't change between attempts)
166    /// - Invalid configuration (needs user intervention)
167    /// - SQL errors (query is invalid)
168    pub const fn is_retriable(&self) -> bool {
169        matches!(self, Error::Io(_) | Error::ConnectionClosed)
170    }
171
172    /// Get error category for observability and logging
173    ///
174    /// Used to categorize errors for metrics, tracing, and error handling decisions.
175    pub const fn category(&self) -> &'static str {
176        match self {
177            Error::Connection(_) => "connection",
178            Error::Authentication(_) => "authentication",
179            Error::Protocol(_) => "protocol",
180            Error::Sql(_) => "sql",
181            Error::JsonDecode(_) => "json_decode",
182            Error::Io(_) => "io",
183            Error::Config(_) => "config",
184            Error::Cancelled => "cancelled",
185            Error::InvalidSchema(_) => "invalid_schema",
186            Error::ConnectionBusy(_) => "connection_busy",
187            Error::InvalidState { .. } => "invalid_state",
188            Error::ConnectionClosed => "connection_closed",
189            Error::Deserialization { .. } => "deserialization",
190            Error::MemoryLimitExceeded { .. } => "memory_limit_exceeded",
191        }
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn test_error_helpers() {
201        let conn_err = Error::connection("failed to connect");
202        assert!(matches!(conn_err, Error::Connection(_)));
203
204        let proto_err = Error::protocol("unexpected message");
205        assert!(matches!(proto_err, Error::Protocol(_)));
206
207        let sql_err = Error::sql("syntax error");
208        assert!(matches!(sql_err, Error::Sql(_)));
209
210        let schema_err = Error::invalid_schema("expected single column");
211        assert!(matches!(schema_err, Error::InvalidSchema(_)));
212    }
213
214    #[test]
215    fn test_error_connection_refused() {
216        let err = Error::connection_refused("localhost", 5432);
217        let msg = err.to_string();
218        assert!(msg.contains("connection refused"));
219        assert!(msg.contains("Is Postgres running?"));
220        assert!(msg.contains("localhost"));
221        assert!(msg.contains("5432"));
222    }
223
224    #[test]
225    fn test_error_invalid_schema_columns() {
226        let err = Error::invalid_schema_columns(2);
227        let msg = err.to_string();
228        assert!(msg.contains("2 columns"));
229        assert!(msg.contains("instead of 1"));
230        assert!(msg.contains("SELECT data FROM"));
231    }
232
233    #[test]
234    fn test_error_auth_failed() {
235        let err = Error::auth_failed("postgres", "invalid password");
236        let msg = err.to_string();
237        assert!(msg.contains("postgres"));
238        assert!(msg.contains("invalid password"));
239        assert!(msg.contains("psql"));
240    }
241
242    #[test]
243    fn test_error_config_invalid() {
244        let err = Error::config_invalid("missing database name");
245        let msg = err.to_string();
246        assert!(msg.contains("invalid configuration"));
247        assert!(msg.contains("postgres://"));
248        assert!(msg.contains("missing database name"));
249    }
250
251    #[test]
252    fn test_error_category() {
253        assert_eq!(Error::connection("test").category(), "connection");
254        assert_eq!(Error::sql("test").category(), "sql");
255        assert_eq!(Error::Cancelled.category(), "cancelled");
256        assert_eq!(Error::ConnectionClosed.category(), "connection_closed");
257    }
258
259    #[test]
260    fn test_error_message_clarity() {
261        // Verify error messages are clear and actionable
262        let err = Error::connection_refused("example.com", 5432);
263        let msg = err.to_string();
264
265        // Should suggest a diagnostic command
266        assert!(msg.contains("pg_isready"));
267
268        // Should include the connection details
269        assert!(msg.contains("example.com"));
270    }
271
272    #[test]
273    fn test_is_retriable() {
274        assert!(Error::ConnectionClosed.is_retriable());
275        assert!(Error::Io(io::Error::new(io::ErrorKind::TimedOut, "timeout")).is_retriable());
276
277        assert!(!Error::connection("test").is_retriable());
278        assert!(!Error::sql("test").is_retriable());
279        assert!(!Error::invalid_schema("test").is_retriable());
280    }
281
282    #[test]
283    fn test_retriable_classification() {
284        // Transient errors should be retriable
285        assert!(Error::ConnectionClosed.is_retriable());
286        assert!(Error::Io(io::Error::new(io::ErrorKind::ConnectionReset, "reset")).is_retriable());
287
288        // Permanent errors should not be retriable
289        assert!(!Error::auth_failed("user", "invalid password").is_retriable());
290        assert!(!Error::sql("syntax error").is_retriable());
291        assert!(!Error::invalid_schema_columns(3).is_retriable());
292    }
293
294    #[test]
295    fn test_deserialization_error() {
296        let err = Error::Deserialization {
297            type_name: "Project".to_string(),
298            details: "missing field `id`".to_string(),
299        };
300        let msg = err.to_string();
301        assert!(msg.contains("Project"));
302        assert!(msg.contains("missing field"));
303        assert_eq!(err.category(), "deserialization");
304    }
305
306    #[test]
307    fn test_deserialization_error_not_retriable() {
308        let err = Error::Deserialization {
309            type_name: "User".to_string(),
310            details: "invalid type".to_string(),
311        };
312        assert!(!err.is_retriable());
313    }
314
315    #[test]
316    fn test_memory_limit_exceeded_error() {
317        let err = Error::MemoryLimitExceeded {
318            limit: 1_000_000,
319            estimated_memory: 1_500_000,
320        };
321        let msg = err.to_string();
322        assert!(msg.contains("1500000"));
323        assert!(msg.contains("1000000"));
324        assert!(msg.contains("memory limit exceeded"));
325        assert_eq!(err.category(), "memory_limit_exceeded");
326    }
327
328    #[test]
329    fn test_memory_limit_exceeded_not_retriable() {
330        let err = Error::MemoryLimitExceeded {
331            limit: 100_000,
332            estimated_memory: 150_000,
333        };
334        assert!(!err.is_retriable());
335    }
336}