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}