Skip to main content

photon_backend/
error.rs

1//! Error types for Photon.
2
3use std::fmt;
4use std::sync::Arc;
5
6use thiserror::Error;
7
8/// Shared error source that keeps [`PhotonError`] [`Clone`].
9pub type SharedError = Arc<dyn std::error::Error + Send + Sync + 'static>;
10
11/// Opaque display-based error source for types that do not implement [`std::error::Error`].
12#[derive(Debug)]
13struct DisplayError(String);
14
15impl fmt::Display for DisplayError {
16    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
17        f.write_str(&self.0)
18    }
19}
20
21impl std::error::Error for DisplayError {}
22
23/// Result type alias for Photon operations.
24pub type Result<T> = std::result::Result<T, PhotonError>;
25
26/// Errors that can occur in Photon operations.
27#[derive(Debug, Clone, Error)]
28pub enum PhotonError {
29    /// Topic not found in registry.
30    #[error("topic not found: {0}")]
31    TopicNotFound(String),
32
33    /// Subscription not found.
34    #[error("subscription not found: {0}")]
35    SubscriptionNotFound(String),
36
37    /// Event not found.
38    #[error("event not found: {0}")]
39    EventNotFound(String),
40
41    /// Invalid topic name.
42    #[error("invalid topic name: {0}")]
43    InvalidTopicName(String),
44
45    /// Payload serialization/deserialization error.
46    #[error("payload error: {0}")]
47    PayloadError(String),
48
49    /// Schema mismatch at publish time.
50    #[error("schema mismatch: {0}")]
51    SchemaMismatch(String),
52
53    /// Topic already registered with different schema.
54    #[error("topic already exists: {0}")]
55    TopicAlreadyExists(String),
56
57    /// Subscription name required for durable subscriptions.
58    #[error("subscription name required for durable subscriptions")]
59    SubscriptionNameRequired,
60
61    /// Persistence / store error (ops metadata adapters).
62    ///
63    /// Prefer [`PhotonError::persistence`] when an underlying store error exists.
64    #[deprecated(note = "use PhotonError::persistence(...) for sourced failures")]
65    #[error("persistence error: {0}")]
66    PersistenceError(String),
67
68    /// Persistence failure with preserved source chain.
69    #[error("persistence error: {context}")]
70    Persistence {
71        /// Human-readable context for the failure.
72        context: String,
73        /// Underlying store / I/O error.
74        #[source]
75        source: SharedError,
76    },
77
78    /// Identity reconstruction failed at the handler boundary.
79    ///
80    /// Produced when [`photon_core::IdentityFactory::reconstruct`] rejects actor JSON
81    /// (or a typed-actor downcast fails). Executor maps this to
82    /// [`crate::instrumentation::FailureReason::IdentityBuild`].
83    #[error("identity error: {0}")]
84    Identity(String),
85
86    /// Internal error (opaque message, no source chain).
87    #[error("internal error: {0}")]
88    Internal(String),
89
90    /// Internal failure with preserved source chain.
91    #[error("internal error: {context}")]
92    Caused {
93        /// Human-readable context for the failure.
94        context: String,
95        /// Underlying error.
96        #[source]
97        source: SharedError,
98    },
99}
100
101impl PhotonError {
102    /// Internal error with a source chain (broker I/O, crypto, etc.).
103    ///
104    /// Accepts any [`std::fmt::Display`] value so callers can wrap SDK errors that do not
105    /// implement [`std::error::Error`] (e.g. some AEAD/crypto error types).
106    pub fn caused(
107        context: impl Into<String>,
108        err: impl fmt::Display + Send + Sync + 'static,
109    ) -> Self {
110        Self::Caused {
111            context: context.into(),
112            source: Arc::new(DisplayError(err.to_string())),
113        }
114    }
115
116    /// Internal error wrapping a real [`std::error::Error`] source chain.
117    pub fn caused_error(
118        context: impl Into<String>,
119        err: impl std::error::Error + Send + Sync + 'static,
120    ) -> Self {
121        Self::Caused {
122            context: context.into(),
123            source: Arc::new(err),
124        }
125    }
126
127    /// Persistence error with a source chain.
128    pub fn persistence(
129        context: impl Into<String>,
130        err: impl std::error::Error + Send + Sync + 'static,
131    ) -> Self {
132        Self::Persistence {
133            context: context.into(),
134            source: Arc::new(err),
135        }
136    }
137}
138
139impl From<serde_json::Error> for PhotonError {
140    fn from(err: serde_json::Error) -> Self {
141        Self::PayloadError(err.to_string())
142    }
143}
144
145impl From<photon_core::IdentityError> for PhotonError {
146    fn from(err: photon_core::IdentityError) -> Self {
147        match err {
148            photon_core::IdentityError::InvalidActor(msg)
149            | photon_core::IdentityError::Factory(msg) => Self::Identity(msg),
150        }
151    }
152}
153
154impl From<anyhow::Error> for PhotonError {
155    fn from(err: anyhow::Error) -> Self {
156        // Prefer the full anyhow chain in the source message (`{#}`).
157        Self::Caused {
158            context: err.to_string(),
159            source: Arc::new(DisplayError(format!("{err:#}"))),
160        }
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use std::error::Error as _;
167
168    use super::*;
169
170    #[derive(Debug, thiserror::Error)]
171    #[error("disk offline")]
172    struct DiskError;
173
174    /// Display-only error type (does not implement `std::error::Error`),
175    /// mimicking SDK types like AEAD crypto errors.
176    struct BadTag;
177
178    impl fmt::Display for BadTag {
179        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
180            f.write_str("bad auth tag")
181        }
182    }
183
184    #[test]
185    fn caused_wraps_display_only_source() {
186        let err = PhotonError::caused("decrypt failed", BadTag);
187        assert_eq!(err.to_string(), "internal error: decrypt failed");
188        let source = err.source().expect("caused keeps a source");
189        assert_eq!(source.to_string(), "bad auth tag");
190    }
191
192    #[test]
193    fn caused_error_preserves_source_chain() {
194        let err = PhotonError::caused_error("flush failed", DiskError);
195        assert_eq!(err.to_string(), "internal error: flush failed");
196        // Source is shared behind an Arc (keeps PhotonError Clone), so verify the
197        // chain by display rather than downcast.
198        let source = err.source().expect("caused_error keeps a source");
199        assert_eq!(source.to_string(), "disk offline");
200    }
201
202    #[test]
203    fn persistence_reports_context_and_source() {
204        let err = PhotonError::persistence("sqlite decode", DiskError);
205        assert_eq!(err.to_string(), "persistence error: sqlite decode");
206        let source = err.source().expect("persistence keeps a source");
207        assert_eq!(source.to_string(), "disk offline");
208    }
209
210    #[test]
211    fn anyhow_conversion_keeps_full_chain() {
212        let err = anyhow::Error::new(DiskError).context("flush checkpoint");
213        let err = PhotonError::from(err);
214        assert_eq!(err.to_string(), "internal error: flush checkpoint");
215        let source = err.source().expect("anyhow conversion keeps a source");
216        let chain = source.to_string();
217        assert!(chain.contains("flush checkpoint"), "chain: {chain}");
218        assert!(chain.contains("disk offline"), "chain: {chain}");
219    }
220
221    #[test]
222    fn serde_json_conversion_maps_to_payload_error() {
223        let err = serde_json::from_str::<serde_json::Value>("{").unwrap_err();
224        let err = PhotonError::from(err);
225        assert!(matches!(err, PhotonError::PayloadError(_)));
226    }
227
228    #[test]
229    fn caused_errors_stay_clone() {
230        let err = PhotonError::caused("original", BadTag);
231        let clone = err.clone();
232        assert_eq!(err.to_string(), clone.to_string());
233        assert_eq!(
234            err.source().map(ToString::to_string),
235            clone.source().map(ToString::to_string)
236        );
237    }
238}