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}