Skip to main content

liminal_sdk/
error.rs

1use alloc::string::String;
2
3/// Error taxonomy returned by the Rust SDK API surface.
4///
5/// The SDK keeps application code independent from transport, protocol framing,
6/// and core implementation error types. Concrete embedded and remote adapters
7/// map their internal failures into these variants.
8///
9/// `Clone` is derived so a failure can be both RETAINED and RETURNED at the same
10/// seam. A store failure that bricks a participant handle is exactly that shape:
11/// the error travels to the caller of the failing operation, while a copy stays
12/// behind as the reason every later call reports
13/// `RemoteParticipantError::StateUnavailable`. Without it the cause is
14/// observable exactly once and only by whoever happened to make the failing
15/// call.
16#[derive(Clone, PartialEq, Eq, thiserror::Error)]
17pub enum SdkError {
18    /// A routed worker refused. The code is the worker's closed vocabulary;
19    /// words remain application data and are omitted from `Debug`.
20    #[error("{words}")]
21    WorkerRefused {
22        /// One of busy, invalid, unavailable or internal.
23        code: String,
24        /// The worker's exact words, bounded by the wire contract.
25        words: String,
26    },
27    /// A receiver handle already owns reception, or a direct receive is active.
28    #[error("push reception is already claimed")]
29    ReceiverAlreadyClaimed,
30
31    /// A `ReadWake` ended a budgeted participant read before a frame arrived;
32    /// the transport remains usable. See `ReadWake` for which reads it reaches.
33    #[error("idle receive interrupted by its owner")]
34    ReadInterrupted,
35
36    /// Establishing, keeping, or recovering a client connection failed.
37    #[error("connection error: {description}")]
38    Connection {
39        /// Human-readable context from the failing connection operation.
40        description: String,
41    },
42
43    /// Encoding, decoding, or interpreting SDK-internal protocol state failed.
44    #[error("protocol error: {description}")]
45    Protocol {
46        /// Human-readable protocol failure context.
47        description: String,
48    },
49
50    /// Serialising an outbound value or deserialising an inbound value failed.
51    #[error("serialization error: {description}")]
52    Serialization {
53        /// Human-readable serialization failure context.
54        description: String,
55    },
56
57    /// A typed message did not satisfy the schema declared for its channel.
58    #[error("type validation failed: {description}")]
59    TypeValidation {
60        /// Human-readable type-validation context.
61        description: String,
62    },
63
64    /// A publish operation encountered application-visible backpressure.
65    #[error("backpressure: {reason}")]
66    Backpressure {
67        /// Human-readable pressure reason.
68        reason: String,
69    },
70
71    /// A publish whose outcome the server could not resolve: it cannot tell
72    /// whether the message was stored.
73    ///
74    /// Decoded from the reply's reason code alone, never from its text. It is
75    /// neither a rejection nor backpressure: the publish may have been stored,
76    /// so the safe response is a retry carrying the SAME idempotency key, which
77    /// answers the original outcome if the row committed.
78    #[error("publish unresolved: {description}")]
79    PublishUnresolved {
80        /// The server's detail for the unresolved publish, verbatim.
81        description: String,
82    },
83
84    /// A conversation operation failed.
85    #[error("conversation {conversation_id}: {description}")]
86    Conversation {
87        /// Application-visible conversation identifier.
88        conversation_id: String,
89        /// Human-readable conversation failure context.
90        description: String,
91    },
92
93    /// Persisted subscription or recovery state could not be read or written.
94    #[error("store error: {description}")]
95    Store {
96        /// Human-readable store failure context.
97        description: String,
98    },
99
100    /// A remote operation ran against a configuration whose transport was never
101    /// connected, so nothing it did could have reached any wire.
102    ///
103    /// `RemoteConfig::new` installs the unconnected in-process transport. Call
104    /// `RemoteConfig::connect_tcp`, `RemoteConfig::connect_tcp_with_auth`,
105    /// `RemoteConfig::connect_websocket`,
106    /// `RemoteConfig::connect_websocket_with_auth`, or (with the `embedded`
107    /// feature) `RemoteConfig::connect_loopback` before publishing,
108    /// subscribing, conversing, or resuming. The SDK refuses here rather than
109    /// encoding a frame it discards and reporting the success that would imply.
110    #[error(
111        "transport not connected: {operation} needs a connected transport; call \
112         RemoteConfig::connect_tcp or RemoteConfig::connect_websocket first"
113    )]
114    NotConnected {
115        /// The SDK operation that refused.
116        operation: String,
117    },
118
119    /// An SDK surface exists but has no machinery behind it, so it refused
120    /// rather than reporting a success it cannot deliver.
121    ///
122    /// This is the SDK being loud about an absent feature. `seam` records where
123    /// the gap is written down (a ledger row, or that nothing records it) and
124    /// `alternative` names the surface that does work today.
125    #[error("{surface} is not wired ({seam}); {alternative}")]
126    Unwired {
127        /// The SDK surface that refused.
128        surface: String,
129        /// Where the gap is recorded, or that nothing records it.
130        seam: String,
131        /// The working surface a caller should reach for instead.
132        alternative: String,
133    },
134}
135
136impl SdkError {
137    /// Classifies the wire reason before retaining any application words.
138    #[cfg(feature = "std")]
139    pub(crate) fn conversation_refusal(
140        conversation_id: u64,
141        reason_code: u16,
142        message: Option<String>,
143    ) -> Self {
144        use alloc::{format, string::ToString};
145        use liminal::protocol::{WorkerRefusal, WorkerRefusalCode};
146        if let Some(code) = WorkerRefusalCode::from_reason_code(reason_code) {
147            let Some(words) = message else {
148                return Self::Protocol {
149                    description: "worker_refusal_missing_words".to_string(),
150                };
151            };
152            return match WorkerRefusal::new(code.name(), &words) {
153                Ok(_) => Self::WorkerRefused {
154                    code: code.name().to_string(),
155                    words,
156                },
157                Err(error) => Self::Protocol {
158                    description: error
159                        .message()
160                        .unwrap_or("worker_refusal_invalid_record")
161                        .to_string(),
162                },
163            };
164        }
165        Self::Conversation {
166            conversation_id: conversation_id.to_string(),
167            description: format!(
168                "server rejected conversation {conversation_id} (reason {reason_code}): {}",
169                message.unwrap_or_else(|| "no detail".to_string())
170            ),
171        }
172    }
173}
174
175impl core::fmt::Debug for SdkError {
176    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
177        match self {
178            Self::WorkerRefused { code, words } => formatter
179                .debug_struct("WorkerRefused")
180                .field("code", code)
181                .field("words_bytes", &words.len())
182                .finish(),
183            Self::ReceiverAlreadyClaimed => formatter.write_str("ReceiverAlreadyClaimed"),
184            Self::ReadInterrupted => formatter.write_str("ReadInterrupted"),
185            Self::Connection { description } => formatter
186                .debug_struct("Connection")
187                .field("description_bytes", &description.len())
188                .finish(),
189            Self::Protocol { description } => formatter
190                .debug_struct("Protocol")
191                .field("description_bytes", &description.len())
192                .finish(),
193            Self::Serialization { description } => formatter
194                .debug_struct("Serialization")
195                .field("description_bytes", &description.len())
196                .finish(),
197            Self::TypeValidation { description } => formatter
198                .debug_struct("TypeValidation")
199                .field("description_bytes", &description.len())
200                .finish(),
201            Self::Backpressure { reason } => formatter
202                .debug_struct("Backpressure")
203                .field("reason_bytes", &reason.len())
204                .finish(),
205            Self::PublishUnresolved { description } => formatter
206                .debug_struct("PublishUnresolved")
207                .field("description_bytes", &description.len())
208                .finish(),
209            Self::Conversation {
210                conversation_id,
211                description,
212            } => formatter
213                .debug_struct("Conversation")
214                .field("conversation_id", conversation_id)
215                .field("description_bytes", &description.len())
216                .finish(),
217            Self::Store { description } => formatter
218                .debug_struct("Store")
219                .field("description_bytes", &description.len())
220                .finish(),
221            Self::NotConnected { operation } => formatter
222                .debug_struct("NotConnected")
223                .field("operation", operation)
224                .finish(),
225            Self::Unwired {
226                surface,
227                seam,
228                alternative,
229            } => formatter
230                .debug_struct("Unwired")
231                .field("surface", surface)
232                .field("seam", seam)
233                .field("alternative", alternative)
234                .finish(),
235        }
236    }
237}