Skip to main content

oxia/
errors.rs

1use thiserror::Error;
2
3/// Errors returned by the Oxia client.
4///
5/// The variants fall into three groups:
6/// - **Semantic results** callers commonly match on ([`KeyNotFound`],
7///   [`UnexpectedVersionId`], [`SessionExpired`], [`RequestTooLarge`],
8///   [`InvalidArgument`]). These are never retryable.
9/// - **Transient failures** that the client's background loops (and, in a later
10///   phase, its operation-level retries) may retry: [`LeaderNotFound`],
11///   [`NoShardForKey`], [`Disconnected`], and some [`Grpc`] codes. See
12///   [`OxiaError::is_retryable`].
13/// - **Terminal failures**: [`Timeout`], [`Decode`], [`Closed`], and the
14///   remaining [`Grpc`] codes.
15///
16/// [`KeyNotFound`]: OxiaError::KeyNotFound
17/// [`UnexpectedVersionId`]: OxiaError::UnexpectedVersionId
18/// [`SessionExpired`]: OxiaError::SessionExpired
19/// [`RequestTooLarge`]: OxiaError::RequestTooLarge
20/// [`InvalidArgument`]: OxiaError::InvalidArgument
21/// [`LeaderNotFound`]: OxiaError::LeaderNotFound
22/// [`NoShardForKey`]: OxiaError::NoShardForKey
23/// [`Disconnected`]: OxiaError::Disconnected
24/// [`Grpc`]: OxiaError::Grpc
25/// [`Timeout`]: OxiaError::Timeout
26/// [`Decode`]: OxiaError::Decode
27/// [`Closed`]: OxiaError::Closed
28#[derive(Error, Debug, Clone)]
29#[non_exhaustive]
30pub enum OxiaError {
31    /// The requested key does not exist.
32    #[error("key not found")]
33    KeyNotFound,
34
35    /// A conditional operation failed because the stored version did not match
36    /// the expected one.
37    #[error("unexpected version id")]
38    UnexpectedVersionId,
39
40    /// The session backing an ephemeral record has expired or been closed.
41    #[error("session no longer exists")]
42    SessionExpired,
43
44    /// A single request, or a batch that cannot be split further, exceeds the
45    /// server's maximum message size.
46    #[error("request is too large")]
47    RequestTooLarge,
48
49    /// An option or argument passed to an operation was invalid.
50    #[error("invalid argument: {0}")]
51    InvalidArgument(String),
52
53    /// No leader is currently available for the shard (e.g. a leader election is
54    /// in progress). Retryable.
55    #[error("no leader available for shard {shard}")]
56    LeaderNotFound {
57        /// The shard without a known leader.
58        shard: i64,
59    },
60
61    /// No shard currently owns the key, because shard assignments have not been
62    /// loaded yet or are momentarily incomplete. Retryable.
63    #[error("no shard owns key {key:?} (assignments not ready)")]
64    NoShardForKey {
65        /// The key that could not be routed.
66        key: String,
67    },
68
69    /// The connection to a server was lost or could not be established, or an
70    /// in-flight request's worker went away. Retryable.
71    #[error("disconnected: {0}")]
72    Disconnected(String),
73
74    /// An operation did not complete before its deadline.
75    #[error("request timed out")]
76    Timeout,
77
78    /// The server returned a gRPC status. Whether it is retryable depends on the
79    /// code (see [`OxiaError::is_retryable`]).
80    #[error("grpc status {code:?}: {message}")]
81    Grpc {
82        /// The gRPC status code.
83        code: tonic::Code,
84        /// The gRPC status message.
85        message: String,
86    },
87
88    /// A server response could not be decoded — a required field was missing, or
89    /// the response did not match the request. Indicates a protocol mismatch or
90    /// a server bug.
91    #[error("failed to decode server response: {0}")]
92    Decode(String),
93
94    /// A shard was split or merged while an operation targeted it. The client
95    /// re-routes such operations to the new shard automatically, so this is not
96    /// normally observed. Retryable (by re-routing).
97    #[error("shard was split or merged")]
98    ShardMoved,
99
100    /// The client has been shut down and can no longer be used.
101    #[error("client is closed")]
102    Closed,
103}
104
105impl OxiaError {
106    /// Whether retrying the operation that produced this error might succeed.
107    ///
108    /// Mirrors the retryable set of the reference Go client: connection loss,
109    /// missing shard leaders / assignments, and the `UNAVAILABLE` / `ABORTED` /
110    /// `CANCELLED` gRPC codes (the last being a shutting-down server's own
111    /// cancellation propagating). Semantic results (e.g.
112    /// [`KeyNotFound`](OxiaError::KeyNotFound)) and terminal failures are not
113    /// retryable.
114    pub fn is_retryable(&self) -> bool {
115        match self {
116            OxiaError::LeaderNotFound { .. }
117            | OxiaError::NoShardForKey { .. }
118            | OxiaError::ShardMoved
119            | OxiaError::Disconnected(_) => true,
120            OxiaError::Grpc { code, .. } => {
121                // `Cancelled` here is the *server's* context cancellation
122                // propagating while it shuts down or drops a connection — the
123                // client never cancels these RPCs itself — so like the other
124                // two it signals a server that may come right back.
125                matches!(
126                    code,
127                    tonic::Code::Unavailable | tonic::Code::Aborted | tonic::Code::Cancelled
128                )
129            }
130            _ => false,
131        }
132    }
133}
134
135impl From<tonic::Status> for OxiaError {
136    fn from(status: tonic::Status) -> Self {
137        // tonic reports client-side transport failures (broken connection,
138        // h2 errors) as code `Unknown` with a local error source, unlike
139        // grpc-go which uses `Unavailable` for the same events. A status the
140        // *server* sent has no local source. Map the transport case to
141        // `Disconnected` so it classifies as retryable, like in Go.
142        if status.code() == tonic::Code::Unknown && std::error::Error::source(&status).is_some() {
143            return OxiaError::Disconnected(format!("transport error: {}", status.message()));
144        }
145        OxiaError::Grpc {
146            code: status.code(),
147            message: status.message().to_string(),
148        }
149    }
150}
151
152impl From<tonic::transport::Error> for OxiaError {
153    fn from(err: tonic::transport::Error) -> Self {
154        OxiaError::Disconnected(err.to_string())
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn retryable_classification() {
164        let msg = String::new();
165        assert!(OxiaError::LeaderNotFound { shard: 1 }.is_retryable());
166        assert!(OxiaError::NoShardForKey { key: "k".into() }.is_retryable());
167        assert!(OxiaError::Disconnected("x".into()).is_retryable());
168        assert!(
169            OxiaError::Grpc {
170                code: tonic::Code::Unavailable,
171                message: msg.clone(),
172            }
173            .is_retryable()
174        );
175        assert!(
176            OxiaError::Grpc {
177                code: tonic::Code::Aborted,
178                message: msg.clone(),
179            }
180            .is_retryable()
181        );
182        assert!(
183            OxiaError::Grpc {
184                code: tonic::Code::Cancelled,
185                message: msg.clone(),
186            }
187            .is_retryable()
188        );
189
190        assert!(!OxiaError::KeyNotFound.is_retryable());
191        assert!(!OxiaError::UnexpectedVersionId.is_retryable());
192        assert!(!OxiaError::SessionExpired.is_retryable());
193        assert!(!OxiaError::RequestTooLarge.is_retryable());
194        assert!(OxiaError::ShardMoved.is_retryable());
195        assert!(!OxiaError::Timeout.is_retryable());
196        assert!(!OxiaError::Closed.is_retryable());
197        assert!(
198            !OxiaError::Grpc {
199                code: tonic::Code::NotFound,
200                message: msg,
201            }
202            .is_retryable()
203        );
204    }
205
206    #[test]
207    fn from_tonic_status_maps_to_grpc() {
208        let err = OxiaError::from(tonic::Status::unavailable("down"));
209        assert!(matches!(
210            err,
211            OxiaError::Grpc {
212                code: tonic::Code::Unavailable,
213                ..
214            }
215        ));
216        assert!(err.is_retryable());
217    }
218}