Skip to main content

s2_sdk/
error.rs

1//! Errors returned by the SDK.
2//!
3//! Operations return the narrowest error type for their surface. Errors expose classification and
4//! accessors relevant to that surface, so callers do not need to inspect display strings or unwrap
5//! the complete error hierarchy.
6
7pub use http::StatusCode;
8use s2_api::v1 as api;
9pub use s2_api::v1::error::ErrorCode;
10
11pub use crate::session::{
12    append::AppendSessionError,
13    read::{CaughtUpError, ReadSessionError},
14};
15use crate::{
16    api::{ApiError, ServerErrorBody},
17    client,
18    types::{FencingToken, StreamPosition, ValidationError},
19};
20
21/// A classified client-side error.
22#[derive(Debug, Clone, thiserror::Error)]
23#[non_exhaustive]
24pub enum ClientError {
25    /// Failed to establish a connection.
26    #[error("connect: {0}")]
27    Connect(String),
28    /// The request timed out.
29    #[error("timeout")]
30    Timeout,
31    /// The connection closed before the response was complete.
32    #[error("connection closed early: {0}")]
33    ConnectionClosedEarly(String),
34    /// The request was canceled.
35    #[error("request canceled: {0}")]
36    RequestCanceled(String),
37    /// The connection ended unexpectedly.
38    #[error("unexpected eof: {0}")]
39    UnexpectedEof(String),
40    /// The connection was reset.
41    #[error("connection reset: {0}")]
42    ConnectionReset(String),
43    /// The connection was aborted.
44    #[error("connection aborted: {0}")]
45    ConnectionAborted(String),
46    /// The connection was refused.
47    #[error("connection refused: {0}")]
48    ConnectionRefused(String),
49    /// Client configuration prevented a request from being attempted.
50    #[error("configuration: {0}")]
51    Configuration(String),
52    /// The request could not be built or encoded.
53    #[error("request build: {0}")]
54    RequestBuild(String),
55    /// The request body could not be compressed.
56    #[error("request compression: {0}")]
57    RequestCompression(String),
58    /// The response body could not be decompressed.
59    #[error("response compression: {0}")]
60    ResponseCompression(String),
61    /// The response body could not be decoded.
62    #[error("response decode: {0}")]
63    ResponseDecode(String),
64    /// A streaming protocol message could not be decoded.
65    #[error("session protocol: {0}")]
66    SessionProtocol(String),
67    /// An otherwise-unclassified client error.
68    #[error("{0}")]
69    Other(String),
70}
71
72impl ClientError {
73    /// Whether retrying the request is safe or sensible.
74    pub fn is_retryable(&self) -> bool {
75        matches!(
76            self,
77            Self::Connect(_)
78                | Self::Timeout
79                | Self::ConnectionClosedEarly(_)
80                | Self::RequestCanceled(_)
81                | Self::UnexpectedEof(_)
82                | Self::ConnectionReset(_)
83                | Self::ConnectionAborted(_)
84                | Self::ConnectionRefused(_)
85        )
86    }
87
88    /// Whether retrying the request cannot duplicate a mutation.
89    pub fn has_no_side_effects(&self) -> bool {
90        matches!(
91            self,
92            Self::Connect(_)
93                | Self::ConnectionRefused(_)
94                | Self::Configuration(_)
95                | Self::RequestBuild(_)
96                | Self::RequestCompression(_)
97        )
98    }
99}
100
101impl From<client::HttpError> for ClientError {
102    fn from(err: client::HttpError) -> Self {
103        let err_msg = err.to_string();
104        match err {
105            client::HttpError::Send(ref send_err) if send_err.is_connect() => {
106                classify_io_source(&err, &err_msg).unwrap_or(Self::Connect(err_msg))
107            }
108            client::HttpError::Send(_) | client::HttpError::Receive(_) => {
109                classify_hyper_source(&err, &err_msg)
110                    .or_else(|| classify_io_source(&err, &err_msg))
111                    .unwrap_or(Self::Other(err_msg))
112            }
113            client::HttpError::RequestBuild(message) => Self::RequestBuild(message),
114            client::HttpError::RequestCompression(message) => Self::RequestCompression(message),
115            client::HttpError::ResponseCompression(message) => Self::ResponseCompression(message),
116            client::HttpError::ResponseDecode(error) => Self::ResponseDecode(error.to_string()),
117            client::HttpError::Timeout => Self::Timeout,
118        }
119    }
120}
121
122fn classify_hyper_source(err: &client::HttpError, err_msg: &str) -> Option<ClientError> {
123    let hyper_err = source_err::<hyper::Error>(err)?;
124    let err_msg = format!("{hyper_err} -> {err_msg}");
125    if hyper_err.is_incomplete_message() {
126        Some(ClientError::ConnectionClosedEarly(err_msg))
127    } else if hyper_err.is_canceled() {
128        Some(ClientError::RequestCanceled(err_msg))
129    } else {
130        None
131    }
132}
133
134fn classify_io_source(err: &client::HttpError, err_msg: &str) -> Option<ClientError> {
135    let io_err = source_err::<std::io::Error>(err)?;
136    let err_msg = format!("{io_err} -> {err_msg}");
137    Some(match io_err.kind() {
138        std::io::ErrorKind::UnexpectedEof => ClientError::UnexpectedEof(err_msg),
139        std::io::ErrorKind::ConnectionReset => ClientError::ConnectionReset(err_msg),
140        std::io::ErrorKind::ConnectionAborted => ClientError::ConnectionAborted(err_msg),
141        std::io::ErrorKind::ConnectionRefused => ClientError::ConnectionRefused(err_msg),
142        _ => return None,
143    })
144}
145
146fn source_err<T: std::error::Error + 'static>(err: &dyn std::error::Error) -> Option<&T> {
147    let mut source = err.source();
148    while let Some(err) = source {
149        if let Some(err) = err.downcast_ref::<T>() {
150            return Some(err);
151        }
152        source = err.source();
153    }
154    None
155}
156
157/// Why an append condition check failed.
158#[derive(Debug, Clone, thiserror::Error)]
159#[non_exhaustive]
160pub enum AppendConditionFailed {
161    /// Fencing token did not match. Contains the expected fencing token.
162    #[error("fencing token mismatch, expected: {0}")]
163    FencingTokenMismatch(FencingToken),
164    /// Sequence number did not match. Contains the expected sequence number.
165    #[error("sequence number mismatch, expected: {0}")]
166    SeqNumMismatch(u64),
167}
168
169impl From<api::stream::AppendConditionFailed> for AppendConditionFailed {
170    fn from(value: api::stream::AppendConditionFailed) -> Self {
171        match value {
172            api::stream::AppendConditionFailed::FencingTokenMismatch(token) => {
173                Self::FencingTokenMismatch(FencingToken::from_server(token.to_string()))
174            }
175            api::stream::AppendConditionFailed::SeqNumMismatch(seq) => Self::SeqNumMismatch(seq),
176        }
177    }
178}
179
180/// Errors that can be returned by any network request.
181#[derive(Debug, Clone, thiserror::Error)]
182#[non_exhaustive]
183pub enum RequestError {
184    /// A client-side error.
185    #[error(transparent)]
186    Client(#[from] ClientError),
187    /// An error returned by the server.
188    #[error(transparent)]
189    Server(#[from] ServerError),
190    /// The access token could not be used as an HTTP header value.
191    #[error("malformed access token: {0}")]
192    MalformedAccessToken(String),
193    #[cfg(feature = "_hidden")]
194    #[doc(hidden)]
195    #[error("access token provider failed: {0}")]
196    AccessTokenProvider(crate::types::AccessTokenProviderError),
197    /// Input validation failed.
198    #[error(transparent)]
199    Validation(#[from] ValidationError),
200}
201
202impl RequestError {
203    /// Whether retrying the operation is safe or sensible.
204    pub fn is_retryable(&self) -> bool {
205        match self {
206            Self::Client(error) => error.is_retryable(),
207            Self::Server(error) => error.is_retryable(),
208            #[cfg(feature = "_hidden")]
209            Self::AccessTokenProvider(error) => error.is_retryable(),
210            Self::MalformedAccessToken(_) | Self::Validation(_) => false,
211        }
212    }
213
214    /// Whether retrying the operation cannot duplicate a mutation.
215    pub fn has_no_side_effects(&self) -> bool {
216        match self {
217            Self::Client(error) => error.has_no_side_effects(),
218            Self::Server(error) => error.has_no_side_effects(),
219            #[cfg(feature = "_hidden")]
220            Self::AccessTokenProvider(_) => true,
221            Self::MalformedAccessToken(_) | Self::Validation(_) => true,
222        }
223    }
224
225    /// Return the server error, if present.
226    pub fn server_error(&self) -> Option<&ServerError> {
227        match self {
228            Self::Server(error) => Some(error),
229            _ => None,
230        }
231    }
232
233    pub(crate) fn is_authentication_error(&self) -> bool {
234        matches!(
235            self,
236            Self::Server(error)
237                if error.status == StatusCode::UNAUTHORIZED && error.code == "authn"
238        )
239    }
240}
241
242impl From<ApiError> for RequestError {
243    fn from(error: ApiError) -> Self {
244        match error {
245            ApiError::Client(error) => Self::Client(error),
246            ApiError::ProtoDecode(error) => {
247                Self::Client(ClientError::ResponseDecode(error.to_string()))
248            }
249            ApiError::TerminalDecode(error) => {
250                Self::Client(ClientError::SessionProtocol(error.to_string()))
251            }
252            ApiError::MalformedAccessToken(error) => Self::MalformedAccessToken(error),
253            #[cfg(feature = "_hidden")]
254            ApiError::AccessTokenProvider(error) => Self::AccessTokenProvider(error),
255            ApiError::Compression(error) => {
256                Self::Client(ClientError::ResponseCompression(error.to_string()))
257            }
258            ApiError::Server(status, response) => {
259                Self::Server(ServerError::from_api(status, response))
260            }
261            other => Self::Client(ClientError::Other(other.to_string())),
262        }
263    }
264}
265
266/// Errors returned by unary read operations.
267#[derive(Debug, Clone, thiserror::Error)]
268#[non_exhaustive]
269pub enum ReadError {
270    /// A network request error.
271    #[error(transparent)]
272    Request(#[from] RequestError),
273    /// The requested position has not been written.
274    #[error("read from an unwritten position. current tail: {0}")]
275    ReadUnwritten(StreamPosition),
276}
277
278impl ReadError {
279    /// Whether retrying the operation is safe or sensible.
280    pub fn is_retryable(&self) -> bool {
281        matches!(self, Self::Request(error) if error.is_retryable())
282    }
283
284    /// Return the underlying request error, if present.
285    pub fn request_error(&self) -> Option<&RequestError> {
286        match self {
287            Self::Request(error) => Some(error),
288            Self::ReadUnwritten(_) => None,
289        }
290    }
291}
292
293impl From<ApiError> for ReadError {
294    fn from(error: ApiError) -> Self {
295        match error {
296            ApiError::ReadUnwritten(tail) => Self::ReadUnwritten(tail.tail.into()),
297            other => Self::Request(other.into()),
298        }
299    }
300}
301
302/// Errors returned by unary append operations.
303#[derive(Debug, Clone, thiserror::Error)]
304#[non_exhaustive]
305pub enum AppendError {
306    /// A network request error.
307    #[error(transparent)]
308    Request(#[from] RequestError),
309    /// The append condition did not match.
310    #[error(transparent)]
311    ConditionFailed(#[from] AppendConditionFailed),
312}
313
314impl AppendError {
315    /// Whether retrying the operation is safe or sensible.
316    pub fn is_retryable(&self) -> bool {
317        matches!(self, Self::Request(error) if error.is_retryable())
318    }
319
320    /// Whether retrying the operation cannot duplicate a mutation.
321    pub fn has_no_side_effects(&self) -> bool {
322        match self {
323            Self::Request(error) => error.has_no_side_effects(),
324            Self::ConditionFailed(_) => true,
325        }
326    }
327
328    /// Return the underlying request error, if present.
329    pub fn request_error(&self) -> Option<&RequestError> {
330        match self {
331            Self::Request(error) => Some(error),
332            Self::ConditionFailed(_) => None,
333        }
334    }
335}
336
337impl From<ApiError> for AppendError {
338    fn from(error: ApiError) -> Self {
339        match error {
340            ApiError::AppendConditionFailed(condition) => Self::ConditionFailed(condition.into()),
341            other => Self::Request(other.into()),
342        }
343    }
344}
345
346/// Errors from producer operations.
347#[derive(Debug, Clone, thiserror::Error)]
348#[non_exhaustive]
349pub enum ProducerError {
350    /// An append-session error encountered while producing records.
351    #[error(transparent)]
352    Append(#[from] AppendSessionError),
353    /// Producer input validation failed before an append was attempted.
354    #[error(transparent)]
355    Validation(#[from] ValidationError),
356    /// The producer was already closed.
357    #[error("producer already closed")]
358    ProducerClosed,
359    /// The producer is closing.
360    #[error("producer is closing")]
361    ProducerClosing,
362    /// The producer was dropped without being closed.
363    #[error("producer dropped without calling close")]
364    ProducerDropped,
365}
366
367impl ProducerError {
368    /// Whether retrying the operation is safe or sensible.
369    pub fn is_retryable(&self) -> bool {
370        match self {
371            Self::Append(error) => error.is_retryable(),
372            Self::Validation(_)
373            | Self::ProducerClosed
374            | Self::ProducerClosing
375            | Self::ProducerDropped => false,
376        }
377    }
378
379    /// Whether retrying the operation cannot duplicate a mutation.
380    pub fn has_no_side_effects(&self) -> bool {
381        match self {
382            Self::Append(error) => error.has_no_side_effects(),
383            Self::Validation(_) | Self::ProducerClosed | Self::ProducerClosing => true,
384            Self::ProducerDropped => false,
385        }
386    }
387
388    /// Return the underlying request error, if present.
389    pub fn request_error(&self) -> Option<&RequestError> {
390        match self {
391            Self::Append(error) => error.request_error(),
392            Self::Validation(_)
393            | Self::ProducerClosed
394            | Self::ProducerClosing
395            | Self::ProducerDropped => None,
396        }
397    }
398}
399
400/// An error returned by an S2 server.
401#[derive(Debug, Clone, thiserror::Error)]
402#[error("{code}: {message}")]
403#[non_exhaustive]
404pub struct ServerError {
405    /// HTTP status returned by the server.
406    pub status: StatusCode,
407    /// Error code.
408    pub code: String,
409    /// Error message.
410    pub message: String,
411}
412
413impl ServerError {
414    pub(crate) fn from_api(status: StatusCode, response: ServerErrorBody) -> Self {
415        Self {
416            status,
417            code: response.code,
418            message: response.message,
419        }
420    }
421
422    /// Return the server error code when it is recognized by this SDK version.
423    ///
424    /// The raw [`code`](Self::code) remains available so callers can preserve and report codes
425    /// introduced by newer servers.
426    pub fn known_code(&self) -> Option<ErrorCode> {
427        self.code.parse().ok()
428    }
429
430    /// Whether retrying the request is safe or sensible for this server error.
431    pub fn is_retryable(&self) -> bool {
432        server_error_is_retryable(self.status, &self.code)
433    }
434
435    /// Whether retrying the request cannot duplicate a mutation.
436    pub fn has_no_side_effects(&self) -> bool {
437        server_error_has_no_side_effects(self.status, &self.code)
438    }
439}
440
441pub(crate) fn server_error_is_retryable(status: StatusCode, code: &str) -> bool {
442    match code.parse::<ErrorCode>() {
443        Ok(code) if code.status() == status => code.is_retryable(),
444        Ok(_) => false,
445        Err(_) => matches!(
446            status,
447            StatusCode::REQUEST_TIMEOUT
448                | StatusCode::TOO_MANY_REQUESTS
449                | StatusCode::INTERNAL_SERVER_ERROR
450                | StatusCode::BAD_GATEWAY
451                | StatusCode::SERVICE_UNAVAILABLE
452                | StatusCode::GATEWAY_TIMEOUT
453        ),
454    }
455}
456
457pub(crate) fn server_error_has_no_side_effects(status: StatusCode, code: &str) -> bool {
458    code.parse::<ErrorCode>()
459        .is_ok_and(|code| code.status() == status && code.has_no_side_effects())
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465
466    fn response(status: StatusCode, code: &str) -> ServerError {
467        ServerError::from_api(
468            status,
469            ServerErrorBody {
470                code: code.to_owned(),
471                message: "test".to_owned(),
472            },
473        )
474    }
475
476    #[test]
477    fn error_response_preserves_raw_and_known_codes() {
478        let known = response(StatusCode::NOT_FOUND, "basin_not_found");
479        assert_eq!(known.code, "basin_not_found");
480        assert_eq!(known.message, "test");
481        assert_eq!(known.known_code(), Some(ErrorCode::BasinNotFound));
482        assert!(known.to_string().contains("basin_not_found"));
483
484        let unknown = response(StatusCode::BAD_REQUEST, "introduced_by_a_newer_server");
485        assert_eq!(unknown.known_code(), None);
486        assert_eq!(unknown.code, "introduced_by_a_newer_server");
487    }
488
489    #[test]
490    fn server_classification_fails_closed_on_status_mismatch() {
491        let mismatch = response(StatusCode::INTERNAL_SERVER_ERROR, "rate_limited");
492        assert!(!mismatch.is_retryable());
493        assert!(!mismatch.has_no_side_effects());
494    }
495
496    #[test]
497    fn unknown_codes_retain_retryable_status_fallback() {
498        let unknown = response(StatusCode::SERVICE_UNAVAILABLE, "future_server_error");
499        assert!(unknown.is_retryable());
500        assert!(!unknown.has_no_side_effects());
501    }
502
503    #[test]
504    fn internal_client_errors_preserve_the_failure_stage() {
505        assert!(matches!(
506            ClientError::from(client::HttpError::RequestBuild("bad request".to_owned())),
507            ClientError::RequestBuild(message) if message == "bad request"
508        ));
509        assert!(matches!(
510            ClientError::from(client::HttpError::RequestCompression("encode".to_owned())),
511            ClientError::RequestCompression(message) if message == "encode"
512        ));
513        assert!(matches!(
514            ClientError::from(client::HttpError::ResponseCompression("decode".to_owned())),
515            ClientError::ResponseCompression(message) if message == "decode"
516        ));
517
518        let json_error = serde_json::from_slice::<serde_json::Value>(b"{")
519            .expect_err("invalid JSON should fail");
520        assert!(matches!(
521            ClientError::from(client::HttpError::ResponseDecode(json_error)),
522            ClientError::ResponseDecode(_)
523        ));
524    }
525
526    #[test]
527    fn nested_errors_expose_request_and_server_errors() {
528        let append = AppendError::Request(RequestError::Server(response(
529            StatusCode::CONFLICT,
530            "transaction_conflict",
531        )));
532
533        assert!(append.is_retryable());
534        assert!(append.has_no_side_effects());
535        let request = append.request_error().expect("request error");
536        assert!(matches!(request, RequestError::Server(_)));
537        let server = request.server_error().expect("server error");
538        assert_eq!(server.known_code(), Some(ErrorCode::TransactionConflict));
539    }
540}