Development Documentation (main branch) - For stable release docs, see docs.rs/eidetica
Skip to main content

eidetica/sync/
error.rs

1//! Error types for the synchronization module.
2
3use std::time::Duration;
4
5use thiserror::Error;
6
7use super::peer_types::Address;
8use crate::{auth::Permission, entry::ID};
9
10/// Which part of an outbound request ran out of time.
11///
12/// Carries what the peer proved about itself before the deadline: a peer that
13/// never completed a connection said nothing, while one that connected and then
14/// stopped answering is reachable and merely unresponsive.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum TimeoutPhase {
17    /// The connection was never established, so the peer may not be there at all.
18    Connect,
19    /// The connection was established and the exchange then stalled, so the peer
20    /// is reachable.
21    Request,
22}
23
24impl TimeoutPhase {
25    /// Whether the peer answered far enough to prove it is reachable.
26    pub fn peer_reachable(&self) -> bool {
27        matches!(self, TimeoutPhase::Request)
28    }
29}
30
31impl std::fmt::Display for TimeoutPhase {
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        match self {
34            TimeoutPhase::Connect => write!(f, "connecting"),
35            TimeoutPhase::Request => write!(f, "awaiting a response"),
36        }
37    }
38}
39
40/// Errors that can occur during synchronization operations.
41#[derive(Debug, Error)]
42#[non_exhaustive]
43pub enum SyncError {
44    /// No transport has been enabled for network operations.
45    #[error("No transport enabled. Call enable_http_transport() first")]
46    NoTransportEnabled,
47
48    /// Sync has not been enabled on the Instance.
49    #[error("Sync is not enabled on this Instance. Call Instance::enable_sync() first")]
50    SyncNotEnabled,
51
52    /// Attempted to start a server when one is already running.
53    #[error("Server already running on {address}")]
54    ServerAlreadyRunning { address: String },
55
56    /// Attempted to stop a server when none is running.
57    #[error("Server not running")]
58    ServerNotRunning,
59
60    /// Unexpected response type received from peer.
61    #[error("Unexpected response type: expected {expected}, got {actual}")]
62    UnexpectedResponse {
63        expected: &'static str,
64        actual: String,
65    },
66
67    /// Network communication error.
68    #[error("Network error: {0}")]
69    Network(String),
70
71    /// Command channel send error.
72    #[error("Failed to send command to background sync: {0}")]
73    CommandSendError(String),
74
75    /// Transport initialization error.
76    #[error("Failed to initialize transport: {0}")]
77    TransportInit(String),
78
79    /// Runtime creation error for async operations.
80    #[error("Failed to create async runtime: {0}")]
81    RuntimeCreation(String),
82
83    /// Server bind error.
84    #[error("Failed to bind server to {address}: {reason}")]
85    ServerBind { address: String, reason: String },
86
87    /// Client connection error.
88    #[error("Failed to connect to {address}: {reason}")]
89    ConnectionFailed { address: String, reason: String },
90
91    /// A peer did not answer within the transport's deadline.
92    ///
93    /// Kept apart from [`SyncError::ConnectionFailed`] and
94    /// [`SyncError::Network`] because a caller reacts differently to silence
95    /// than to a refusal: a peer that never answered is worth asking again,
96    /// while one that answered with an error usually is not. `phase` carries
97    /// what the peer proved about itself before the deadline.
98    #[error("Request to {address} timed out after {elapsed:?} while {phase}")]
99    Timeout {
100        address: String,
101        phase: TimeoutPhase,
102        elapsed: Duration,
103    },
104
105    /// Device key not found in backend storage.
106    #[error("Device key '{key_name}' not found in backend storage")]
107    DeviceKeyNotFound { key_name: String },
108
109    /// Transport type not supported by this transport implementation.
110    #[error("Transport type '{transport_type}' not supported")]
111    UnsupportedTransport { transport_type: String },
112
113    /// Invalid address format.
114    #[error("Invalid address: {0}")]
115    InvalidAddress(String),
116
117    /// Peer not found.
118    #[error("Peer not found: {0}")]
119    PeerNotFound(String),
120
121    /// Peer already exists.
122    #[error("Peer already exists: {0}")]
123    PeerAlreadyExists(String),
124
125    /// Serialization error.
126    #[error("Serialization error: {0}")]
127    SerializationError(String),
128
129    /// Protocol version mismatch.
130    #[error("Protocol version mismatch: expected {expected}, received {received}")]
131    ProtocolMismatch { expected: u32, received: u32 },
132
133    /// Handshake failed.
134    #[error("Handshake failed: {0}")]
135    HandshakeFailed(String),
136
137    /// Entry not found in backend storage.
138    #[error("Entry not found: {0}")]
139    EntryNotFound(ID),
140
141    /// Invalid entry received (validation failed).
142    #[error("Invalid entry: {0}")]
143    InvalidEntry(String),
144
145    /// Sync protocol error.
146    #[error("Sync protocol error: {0}")]
147    SyncProtocolError(String),
148
149    /// Backend storage error.
150    #[error("Backend error: {0}")]
151    BackendError(String),
152
153    /// Bootstrap request not found.
154    #[error("Bootstrap request not found: {0}")]
155    RequestNotFound(String),
156
157    /// Bootstrap request already exists.
158    #[error("Bootstrap request already exists: {0}")]
159    RequestAlreadyExists(String),
160
161    /// Invalid bootstrap request state.
162    #[error(
163        "Invalid request state for '{request_id}': expected {expected_status}, found {current_status}"
164    )]
165    InvalidRequestState {
166        request_id: String,
167        current_status: String,
168        expected_status: String,
169    },
170
171    /// Invalid data format in stored bootstrap request.
172    #[error("Invalid data: {0}")]
173    InvalidData(String),
174
175    /// Insufficient permission for the requested operation.
176    #[error(
177        "Insufficient permission for request '{request_id}': required {required_permission}, but key has {actual_permission:?}"
178    )]
179    InsufficientPermission {
180        request_id: String,
181        required_permission: String,
182        actual_permission: Permission,
183    },
184
185    /// A request that would be served data carried no proof of key possession.
186    #[error("Authentication required to read database '{0}'")]
187    AuthenticationRequired(String),
188
189    /// The proof of key possession did not hold up.
190    #[error("Authentication failed: {0}")]
191    AuthenticationFailed(String),
192
193    /// The caller proved its key, but that key has no read access.
194    #[error("Permission denied: {0}")]
195    PermissionDenied(String),
196
197    /// Invalid public key provided.
198    #[error("Invalid public key: {reason}")]
199    InvalidPublicKey { reason: String },
200
201    /// Invalid key name provided.
202    #[error("Invalid key name: {reason}")]
203    InvalidKeyName { reason: String },
204
205    /// Instance has been dropped and is no longer available.
206    #[error("Instance has been dropped")]
207    InstanceDropped,
208
209    /// Bootstrap request is pending manual approval.
210    #[error("Bootstrap request pending approval (request_id: {request_id}): {message}")]
211    BootstrapPending { request_id: String, message: String },
212
213    /// Bootstrap request was rejected by an administrator.
214    #[error("Bootstrap request rejected (request_id: {request_id}): {message}")]
215    BootstrapRejected { request_id: String, message: String },
216
217    /// Transport configuration type mismatch.
218    #[error("Transport config type mismatch for '{name}': expected '{expected}', found '{found}'")]
219    TransportTypeMismatch {
220        name: String,
221        expected: String,
222        found: String,
223    },
224
225    /// Transport not found by name.
226    #[error("Transport not found: {name}")]
227    TransportNotFound { name: String },
228
229    /// No transport can handle the given address.
230    #[error("No transport can handle address: {address:?}")]
231    NoTransportForAddress { address: Address },
232
233    /// Multiple transport operations failed.
234    #[error("Multiple transport errors: {}", errors.join(", "))]
235    MultipleTransportErrors { errors: Vec<String> },
236}
237
238impl SyncError {
239    /// Check if this is a configuration error: the sync stack isn't ready
240    /// (sync not attached or no transport registered).
241    pub fn is_configuration_error(&self) -> bool {
242        matches!(
243            self,
244            SyncError::NoTransportEnabled | SyncError::SyncNotEnabled
245        )
246    }
247
248    /// Check if this is a server lifecycle error.
249    pub fn is_server_error(&self) -> bool {
250        matches!(
251            self,
252            SyncError::ServerAlreadyRunning { .. }
253                | SyncError::ServerNotRunning
254                | SyncError::ServerBind { .. }
255        )
256    }
257
258    /// Check if this is a network/connection error.
259    ///
260    /// Includes timeouts: a deadline that expires is one way a network call
261    /// fails, and callers classifying by this predicate should not have to
262    /// learn about a new variant to keep treating it as one.
263    pub fn is_network_error(&self) -> bool {
264        matches!(
265            self,
266            SyncError::Network(_) | SyncError::ConnectionFailed { .. } | SyncError::Timeout { .. }
267        )
268    }
269
270    /// Check if this is a timeout, and if so in which phase.
271    ///
272    /// `Some(TimeoutPhase::Request)` means the peer was reachable and stopped
273    /// answering; `Some(TimeoutPhase::Connect)` means it never answered at all.
274    pub fn timeout_phase(&self) -> Option<TimeoutPhase> {
275        match self {
276            SyncError::Timeout { phase, .. } => Some(*phase),
277            _ => None,
278        }
279    }
280
281    /// Check if this is a timeout rather than a refusal or a protocol failure.
282    pub fn is_timeout(&self) -> bool {
283        self.timeout_phase().is_some()
284    }
285
286    /// Check if this is a protocol error (unexpected response).
287    pub fn is_protocol_error(&self) -> bool {
288        matches!(self, SyncError::UnexpectedResponse { .. })
289    }
290
291    /// Check if this is a not found error.
292    pub fn is_not_found(&self) -> bool {
293        matches!(
294            self,
295            SyncError::PeerNotFound(_) | SyncError::EntryNotFound(_)
296        )
297    }
298
299    /// Check if this is a validation error.
300    pub fn is_validation_error(&self) -> bool {
301        matches!(
302            self,
303            SyncError::InvalidEntry(_)
304                | SyncError::InvalidPublicKey { .. }
305                | SyncError::InvalidKeyName { .. }
306        )
307    }
308
309    /// Check if this is a backend error.
310    pub fn is_backend_error(&self) -> bool {
311        matches!(self, SyncError::BackendError(_))
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    fn timeout(phase: TimeoutPhase) -> SyncError {
320        SyncError::Timeout {
321            address: "127.0.0.1:8080".to_string(),
322            phase,
323            elapsed: Duration::from_secs(30),
324        }
325    }
326
327    /// The point of the variant: a caller can tell silence from a refusal
328    /// without matching on the message text.
329    #[test]
330    fn a_timeout_is_distinguishable_from_a_refusal() {
331        let refused = SyncError::ConnectionFailed {
332            address: "127.0.0.1:8080".to_string(),
333            reason: "connection refused".to_string(),
334        };
335
336        assert!(timeout(TimeoutPhase::Request).is_timeout());
337        assert!(!refused.is_timeout());
338        assert!(!SyncError::Network("read failed".to_string()).is_timeout());
339    }
340
341    /// Callers that already classify by `is_network_error` keep working: a
342    /// deadline expiring is still a way a network call failed.
343    #[test]
344    fn a_timeout_is_still_a_network_error() {
345        assert!(timeout(TimeoutPhase::Connect).is_network_error());
346        assert!(timeout(TimeoutPhase::Request).is_network_error());
347    }
348
349    /// The reachability distinction the two transports draw survives into the
350    /// error, rather than being encoded by which variant was chosen.
351    #[test]
352    fn the_phase_records_whether_the_peer_answered_at_all() {
353        assert_eq!(
354            timeout(TimeoutPhase::Connect).timeout_phase(),
355            Some(TimeoutPhase::Connect)
356        );
357        assert!(!TimeoutPhase::Connect.peer_reachable());
358        assert!(TimeoutPhase::Request.peer_reachable());
359
360        assert_eq!(SyncError::ServerNotRunning.timeout_phase(), None);
361    }
362
363    /// The message has to name the peer and the deadline, since that is what a
364    /// log reader has to act on.
365    #[test]
366    fn the_message_names_the_peer_and_the_deadline() {
367        let rendered = timeout(TimeoutPhase::Request).to_string();
368        assert!(rendered.contains("127.0.0.1:8080"), "{rendered}");
369        assert!(rendered.contains("30s"), "{rendered}");
370        assert!(rendered.contains("awaiting a response"), "{rendered}");
371    }
372}