1use std::io;
4use thiserror::Error;
5
6#[derive(Debug, Error)]
8#[non_exhaustive]
9pub enum WireError {
10 #[error("connection error: {0}")]
12 Connection(String),
13
14 #[error("authentication failed: {0}")]
16 Authentication(String),
17
18 #[error("protocol error: {0}")]
20 Protocol(String),
21
22 #[error("sql error: {0}")]
24 Sql(String),
25
26 #[error("json decode error: {0}")]
28 JsonDecode(#[from] serde_json::Error),
29
30 #[error("io error: {0}")]
32 Io(#[from] io::Error),
33
34 #[error("invalid configuration: {0}")]
36 Config(String),
37
38 #[error("query cancelled")]
40 Cancelled,
41
42 #[error("invalid result schema: {0}")]
44 InvalidSchema(String),
45
46 #[error("connection busy: {0}")]
48 ConnectionBusy(String),
49
50 #[error("invalid connection state: expected {expected}, got {actual}")]
52 InvalidState {
53 expected: String,
55 actual: String,
57 },
58
59 #[error("connection closed")]
61 ConnectionClosed,
62
63 #[error("deserialization error for type '{type_name}': {details}")]
68 Deserialization {
69 type_name: String,
71 details: String,
73 },
74
75 #[error("memory limit exceeded: {estimated_memory} bytes buffered > {limit} bytes limit")]
90 MemoryLimitExceeded {
91 limit: usize,
93 estimated_memory: usize,
95 },
96}
97
98pub type Result<T> = std::result::Result<T, WireError>;
100
101impl WireError {
102 pub fn connection<S: Into<String>>(msg: S) -> Self {
104 WireError::Connection(msg.into())
105 }
106
107 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 pub fn protocol<S: Into<String>>(msg: S) -> Self {
118 WireError::Protocol(msg.into())
119 }
120
121 pub fn sql<S: Into<String>>(msg: S) -> Self {
123 WireError::Sql(msg.into())
124 }
125
126 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 pub fn invalid_schema<S: Into<String>>(msg: S) -> Self {
138 WireError::InvalidSchema(msg.into())
139 }
140
141 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 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 pub const fn is_retriable(&self) -> bool {
170 matches!(self, WireError::Io(_) | WireError::ConnectionClosed)
171 }
172
173 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 let err = WireError::connection_refused("example.com", 5432);
264 let msg = err.to_string();
265
266 assert!(msg.contains("pg_isready"));
268
269 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 assert!(WireError::ConnectionClosed.is_retriable());
287 assert!(
288 WireError::Io(io::Error::new(io::ErrorKind::ConnectionReset, "reset")).is_retriable()
289 );
290
291 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}