Skip to main content

miden_client/rpc/errors/
mod.rs

1use alloc::boxed::Box;
2use alloc::string::{String, ToString};
3use core::error::Error;
4use core::fmt;
5
6pub use miden_objects::ConversionError;
7use miden_protocol::errors::NoteError;
8use miden_protocol::note::NoteId;
9use miden_protocol::utils::serde::DeserializationError;
10use thiserror::Error;
11
12use super::RpcEndpoint;
13
14pub mod node;
15pub use node::{AddTransactionError, EndpointError, RegisterAccountError};
16
17// RPC ERROR
18// ================================================================================================
19
20#[derive(Debug, Error)]
21pub enum RpcError {
22    #[error("accept header validation failed")]
23    AcceptHeaderError(#[from] AcceptHeaderError),
24    #[error("failed to connect to the Miden node")]
25    ConnectionError(#[source] Box<dyn Error + Send + Sync + 'static>),
26    #[error("failed to deserialize response from the Miden node: {0}")]
27    DeserializationError(String),
28    #[error("Miden node response is missing expected field '{0}'")]
29    ExpectedDataMissing(String),
30    #[error("rpc pagination error: {0}")]
31    PaginationError(String),
32    #[error("received an invalid response from the Miden node: {0}")]
33    InvalidResponse(String),
34    #[error("grpc request failed for {endpoint}: {error_kind}{}",
35        endpoint_error.as_ref().map_or(String::new(), |e| format!(" ({e})")))]
36    RequestError {
37        endpoint: RpcEndpoint,
38        error_kind: GrpcError,
39        endpoint_error: Option<EndpointError>,
40        #[source]
41        source: Option<Box<dyn Error + Send + Sync + 'static>>,
42    },
43    #[error("note {0} was not found on the Miden node")]
44    NoteNotFound(NoteId),
45    #[error("failed to seal transaction inputs for submission: {0}")]
46    TransactionInputsSealingFailed(String),
47    #[error("the transaction encryption key served by the node was rejected: {0}")]
48    TransactionEncryptionKeyRejected(String),
49    #[error("invalid Miden node endpoint '{0}'; expected format: https://host:port")]
50    InvalidNodeEndpoint(String),
51}
52
53impl RpcError {
54    /// Returns the typed endpoint error if this is a request error, or `None` otherwise.
55    pub fn endpoint_error(&self) -> Option<&EndpointError> {
56        match self {
57            Self::RequestError { endpoint_error, .. } => endpoint_error.as_ref(),
58            _ => None,
59        }
60    }
61
62    /// Returns whether this is a submission rejected because the transaction inputs were sealed
63    /// against an encryption key the validator does not hold.
64    pub fn is_stale_transaction_encryption_key(&self) -> bool {
65        matches!(
66            self,
67            Self::RequestError {
68                endpoint: RpcEndpoint::SubmitProvenTx | RpcEndpoint::SubmitProvenBatch,
69                error_kind: GrpcError::FailedPrecondition,
70                ..
71            }
72        )
73    }
74
75    /// Returns whether this is a submission that came back without a definite outcome, so the node
76    /// may or may not have accepted the transaction.
77    ///
78    /// In practice a lost submission arrives as `Unavailable`, `Unknown` or `Cancelled`. The match
79    /// lists the codes the node issues deliberately instead, so a code this client does not
80    /// recognize stays on the "may have landed" side.
81    pub fn is_indeterminate_submission(&self) -> bool {
82        let Self::RequestError {
83            endpoint: RpcEndpoint::SubmitProvenTx | RpcEndpoint::SubmitProvenBatch,
84            error_kind,
85            ..
86        } = self
87        else {
88            return false;
89        };
90
91        !matches!(
92            error_kind,
93            // The node processed the request and rejected it
94            GrpcError::InvalidArgument
95                | GrpcError::FailedPrecondition
96                | GrpcError::NotFound
97                | GrpcError::AlreadyExists
98                | GrpcError::OutOfRange
99                | GrpcError::ResourceExhausted
100                | GrpcError::Unauthenticated
101                | GrpcError::PermissionDenied
102                | GrpcError::Unimplemented
103        )
104    }
105}
106
107impl From<DeserializationError> for RpcError {
108    fn from(err: DeserializationError) -> Self {
109        Self::DeserializationError(err.to_string())
110    }
111}
112
113impl From<NoteError> for RpcError {
114    fn from(err: NoteError) -> Self {
115        Self::DeserializationError(err.to_string())
116    }
117}
118
119impl From<RpcConversionError> for RpcError {
120    fn from(err: RpcConversionError) -> Self {
121        Self::DeserializationError(err.to_string())
122    }
123}
124
125impl From<ConversionError> for RpcError {
126    fn from(err: ConversionError) -> Self {
127        Self::DeserializationError(err.to_string())
128    }
129}
130
131// RPC CONVERSION ERROR
132// ================================================================================================
133
134#[derive(Debug, Error)]
135pub enum RpcConversionError {
136    #[error("invalid field in node response: {0}")]
137    InvalidField(String),
138    #[error("field `{field_name}` expected to be present in protobuf representation of {entity}")]
139    MissingFieldInProtobufRepresentation {
140        entity: &'static str,
141        field_name: &'static str,
142    },
143    #[error("failed to convert a canonical object message: {0}")]
144    CanonicalConversion(#[from] ConversionError),
145}
146
147// GRPC ERROR KIND
148// ================================================================================================
149
150/// Categorizes gRPC errors based on their status codes and common patterns
151#[derive(Debug, Error)]
152pub enum GrpcError {
153    #[error("resource not found")]
154    NotFound,
155    #[error("invalid request parameters")]
156    InvalidArgument,
157    #[error("permission denied")]
158    PermissionDenied,
159    #[error("resource already exists")]
160    AlreadyExists,
161    #[error("request was rate-limited or the node's resources are exhausted; retry after a delay")]
162    ResourceExhausted,
163    #[error("precondition failed")]
164    FailedPrecondition,
165    #[error("operation was cancelled")]
166    Cancelled,
167    #[error("request to Miden node timed out; the node may be under heavy load")]
168    DeadlineExceeded,
169    #[error("Miden node is unavailable; check that the node is running and reachable")]
170    Unavailable,
171    #[error("Miden node returned an internal error; this is likely a node-side issue")]
172    Internal,
173    #[error("the requested method is not implemented by this version of the Miden node")]
174    Unimplemented,
175    #[error(
176        "request was rejected as unauthenticated; check your credentials and connection settings"
177    )]
178    Unauthenticated,
179    #[error("operation was aborted")]
180    Aborted,
181    #[error("operation was attempted past the valid range")]
182    OutOfRange,
183    #[error("unrecoverable data loss or corruption")]
184    DataLoss,
185    #[error("unknown error: {0}")]
186    Unknown(String),
187}
188
189impl GrpcError {
190    /// Creates a `GrpcError` from a gRPC status code following the official specification
191    /// <https://github.com/grpc/grpc/blob/master/doc/statuscodes.md#status-codes-and-their-use-in-grpc>
192    pub fn from_code(code: i32, message: Option<String>) -> Self {
193        match code {
194            1 => Self::Cancelled,
195            2 => Self::Unknown(message.unwrap_or_default()),
196            3 => Self::InvalidArgument,
197            4 => Self::DeadlineExceeded,
198            5 => Self::NotFound,
199            6 => Self::AlreadyExists,
200            7 => Self::PermissionDenied,
201            8 => Self::ResourceExhausted,
202            9 => Self::FailedPrecondition,
203            10 => Self::Aborted,
204            11 => Self::OutOfRange,
205            12 => Self::Unimplemented,
206            13 => Self::Internal,
207            14 => Self::Unavailable,
208            15 => Self::DataLoss,
209            16 => Self::Unauthenticated,
210            _ => Self::Unknown(
211                message.unwrap_or_else(|| format!("Unknown gRPC status code: {code}")),
212            ),
213        }
214    }
215}
216
217// ACCEPT HEADER ERROR
218// ================================================================================================
219
220// TODO: Accept header errors are still parsed from message strings, which is fragile. Ideally the
221// node would return structured error codes for these too. See #1129.
222
223/// Errors that can occur during accept header validation.
224#[derive(Debug, Error)]
225pub enum AcceptHeaderError {
226    #[error("server rejected request - please check your version and network settings ({0})")]
227    NoSupportedMediaRange(AcceptHeaderContext),
228    #[error("server rejected request - parsing error: {0}")]
229    ParsingError(String),
230}
231
232/// Extra context attached to Accept header negotiation failures.
233#[derive(Debug, Clone)]
234pub struct AcceptHeaderContext {
235    pub client_version: String,
236    pub genesis_commitment: String,
237}
238
239impl fmt::Display for AcceptHeaderContext {
240    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
241        write!(
242            f,
243            "client version: {}, genesis commitment: {}",
244            self.client_version, self.genesis_commitment
245        )
246    }
247}
248
249impl AcceptHeaderError {
250    /// Try to parse an accept header error from a message string, adding context.
251    pub fn try_from_message_with_context(
252        message: &str,
253        context: AcceptHeaderContext,
254    ) -> Option<Self> {
255        // Check for the main compatibility error message
256        if message.contains(
257            "server does not support any of the specified application/vnd.miden content types",
258        ) {
259            return Some(Self::NoSupportedMediaRange(context));
260        }
261        if message.contains("genesis value failed to parse")
262            || message.contains("version value failed to parse")
263        {
264            return Some(Self::ParsingError(message.to_string()));
265        }
266        None
267    }
268}
269
270// TESTS
271// ================================================================================================
272
273#[cfg(test)]
274mod tests {
275    use super::{GrpcError, RpcEndpoint, RpcError};
276
277    fn submission_failure(error_kind: GrpcError) -> RpcError {
278        RpcError::RequestError {
279            endpoint: RpcEndpoint::SubmitProvenTx,
280            error_kind,
281            endpoint_error: None,
282            source: None,
283        }
284    }
285
286    /// None of these carry evidence about whether the node processed the request, so a submission
287    /// that fails with any of them may still be in the mempool.
288    #[test]
289    fn transport_failures_are_indeterminate() {
290        for error_kind in [
291            GrpcError::Unavailable,
292            GrpcError::Unknown("transport error".into()),
293            GrpcError::Cancelled,
294            GrpcError::DeadlineExceeded,
295            GrpcError::Internal,
296            GrpcError::Aborted,
297        ] {
298            let label = format!("{error_kind:?}");
299            assert!(
300                submission_failure(error_kind).is_indeterminate_submission(),
301                "{label} must be treated as indeterminate"
302            );
303        }
304    }
305
306    /// Codes the node issues deliberately are an answer, so the transaction did not land.
307    #[test]
308    fn deliberate_rejections_are_definite() {
309        for error_kind in [
310            GrpcError::InvalidArgument,
311            GrpcError::FailedPrecondition,
312            GrpcError::ResourceExhausted,
313            GrpcError::NotFound,
314            GrpcError::AlreadyExists,
315            GrpcError::OutOfRange,
316            GrpcError::Unauthenticated,
317            GrpcError::PermissionDenied,
318            GrpcError::Unimplemented,
319        ] {
320            let label = format!("{error_kind:?}");
321            assert!(
322                !submission_failure(error_kind).is_indeterminate_submission(),
323                "{label} is a rejection, not an unknown outcome"
324            );
325        }
326    }
327
328    /// A read that fails leaves nothing behind to recover, so it never qualifies.
329    #[test]
330    fn reads_are_never_indeterminate_submissions() {
331        let err = RpcError::RequestError {
332            endpoint: RpcEndpoint::GetBlockHeaderByNumber,
333            error_kind: GrpcError::Unavailable,
334            endpoint_error: None,
335            source: None,
336        };
337
338        assert!(!err.is_indeterminate_submission());
339    }
340
341    /// A connection that was never opened is not a submission failure: nothing was sent.
342    #[test]
343    fn connection_errors_are_not_indeterminate() {
344        let err = RpcError::ConnectionError("no route to host".into());
345
346        assert!(!err.is_indeterminate_submission());
347    }
348}