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