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    /// Input validation failed.
194    #[error(transparent)]
195    Validation(#[from] ValidationError),
196}
197
198impl RequestError {
199    /// Whether retrying the operation is safe or sensible.
200    pub fn is_retryable(&self) -> bool {
201        match self {
202            Self::Client(error) => error.is_retryable(),
203            Self::Server(error) => error.is_retryable(),
204            Self::MalformedAccessToken(_) | Self::Validation(_) => false,
205        }
206    }
207
208    /// Whether retrying the operation cannot duplicate a mutation.
209    pub fn has_no_side_effects(&self) -> bool {
210        match self {
211            Self::Client(error) => error.has_no_side_effects(),
212            Self::Server(error) => error.has_no_side_effects(),
213            Self::MalformedAccessToken(_) | Self::Validation(_) => true,
214        }
215    }
216
217    /// Return the server error, if present.
218    pub fn server_error(&self) -> Option<&ServerError> {
219        match self {
220            Self::Server(error) => Some(error),
221            _ => None,
222        }
223    }
224}
225
226impl From<ApiError> for RequestError {
227    fn from(error: ApiError) -> Self {
228        match error {
229            ApiError::Client(error) => Self::Client(error),
230            ApiError::ProtoDecode(error) => {
231                Self::Client(ClientError::ResponseDecode(error.to_string()))
232            }
233            ApiError::TerminalDecode(error) => {
234                Self::Client(ClientError::SessionProtocol(error.to_string()))
235            }
236            ApiError::MalformedAccessToken(error) => Self::MalformedAccessToken(error),
237            ApiError::Compression(error) => {
238                Self::Client(ClientError::ResponseCompression(error.to_string()))
239            }
240            ApiError::Server(status, response) => {
241                Self::Server(ServerError::from_api(status, response))
242            }
243            other => Self::Client(ClientError::Other(other.to_string())),
244        }
245    }
246}
247
248/// Errors returned by unary read operations.
249#[derive(Debug, Clone, thiserror::Error)]
250#[non_exhaustive]
251pub enum ReadError {
252    /// A network request error.
253    #[error(transparent)]
254    Request(#[from] RequestError),
255    /// The requested position has not been written.
256    #[error("read from an unwritten position. current tail: {0}")]
257    ReadUnwritten(StreamPosition),
258}
259
260impl ReadError {
261    /// Whether retrying the operation is safe or sensible.
262    pub fn is_retryable(&self) -> bool {
263        matches!(self, Self::Request(error) if error.is_retryable())
264    }
265
266    /// Return the underlying request error, if present.
267    pub fn request_error(&self) -> Option<&RequestError> {
268        match self {
269            Self::Request(error) => Some(error),
270            Self::ReadUnwritten(_) => None,
271        }
272    }
273}
274
275impl From<ApiError> for ReadError {
276    fn from(error: ApiError) -> Self {
277        match error {
278            ApiError::ReadUnwritten(tail) => Self::ReadUnwritten(tail.tail.into()),
279            other => Self::Request(other.into()),
280        }
281    }
282}
283
284/// Errors returned by unary append operations.
285#[derive(Debug, Clone, thiserror::Error)]
286#[non_exhaustive]
287pub enum AppendError {
288    /// A network request error.
289    #[error(transparent)]
290    Request(#[from] RequestError),
291    /// The append condition did not match.
292    #[error(transparent)]
293    ConditionFailed(#[from] AppendConditionFailed),
294}
295
296impl AppendError {
297    /// Whether retrying the operation is safe or sensible.
298    pub fn is_retryable(&self) -> bool {
299        matches!(self, Self::Request(error) if error.is_retryable())
300    }
301
302    /// Whether retrying the operation cannot duplicate a mutation.
303    pub fn has_no_side_effects(&self) -> bool {
304        match self {
305            Self::Request(error) => error.has_no_side_effects(),
306            Self::ConditionFailed(_) => true,
307        }
308    }
309
310    /// Return the underlying request error, if present.
311    pub fn request_error(&self) -> Option<&RequestError> {
312        match self {
313            Self::Request(error) => Some(error),
314            Self::ConditionFailed(_) => None,
315        }
316    }
317}
318
319impl From<ApiError> for AppendError {
320    fn from(error: ApiError) -> Self {
321        match error {
322            ApiError::AppendConditionFailed(condition) => Self::ConditionFailed(condition.into()),
323            other => Self::Request(other.into()),
324        }
325    }
326}
327
328/// Errors from producer operations.
329#[derive(Debug, Clone, thiserror::Error)]
330#[non_exhaustive]
331pub enum ProducerError {
332    /// An append-session error encountered while producing records.
333    #[error(transparent)]
334    Append(#[from] AppendSessionError),
335    /// Producer input validation failed before an append was attempted.
336    #[error(transparent)]
337    Validation(#[from] ValidationError),
338    /// The producer was already closed.
339    #[error("producer already closed")]
340    ProducerClosed,
341    /// The producer is closing.
342    #[error("producer is closing")]
343    ProducerClosing,
344    /// The producer was dropped without being closed.
345    #[error("producer dropped without calling close")]
346    ProducerDropped,
347}
348
349impl ProducerError {
350    /// Whether retrying the operation is safe or sensible.
351    pub fn is_retryable(&self) -> bool {
352        match self {
353            Self::Append(error) => error.is_retryable(),
354            Self::Validation(_)
355            | Self::ProducerClosed
356            | Self::ProducerClosing
357            | Self::ProducerDropped => false,
358        }
359    }
360
361    /// Whether retrying the operation cannot duplicate a mutation.
362    pub fn has_no_side_effects(&self) -> bool {
363        match self {
364            Self::Append(error) => error.has_no_side_effects(),
365            Self::Validation(_) | Self::ProducerClosed | Self::ProducerClosing => true,
366            Self::ProducerDropped => false,
367        }
368    }
369
370    /// Return the underlying request error, if present.
371    pub fn request_error(&self) -> Option<&RequestError> {
372        match self {
373            Self::Append(error) => error.request_error(),
374            Self::Validation(_)
375            | Self::ProducerClosed
376            | Self::ProducerClosing
377            | Self::ProducerDropped => None,
378        }
379    }
380}
381
382/// An error returned by an S2 server.
383#[derive(Debug, Clone, thiserror::Error)]
384#[error("{code}: {message}")]
385#[non_exhaustive]
386pub struct ServerError {
387    /// HTTP status returned by the server.
388    pub status: StatusCode,
389    /// Error code.
390    pub code: String,
391    /// Error message.
392    pub message: String,
393}
394
395impl ServerError {
396    pub(crate) fn from_api(status: StatusCode, response: ServerErrorBody) -> Self {
397        Self {
398            status,
399            code: response.code,
400            message: response.message,
401        }
402    }
403
404    /// Return the server error code when it is recognized by this SDK version.
405    ///
406    /// The raw [`code`](Self::code) remains available so callers can preserve and report codes
407    /// introduced by newer servers.
408    pub fn known_code(&self) -> Option<ErrorCode> {
409        self.code.parse().ok()
410    }
411
412    /// Whether retrying the request is safe or sensible for this server error.
413    pub fn is_retryable(&self) -> bool {
414        server_error_is_retryable(self.status, &self.code)
415    }
416
417    /// Whether retrying the request cannot duplicate a mutation.
418    pub fn has_no_side_effects(&self) -> bool {
419        server_error_has_no_side_effects(self.status, &self.code)
420    }
421}
422
423pub(crate) fn server_error_is_retryable(status: StatusCode, code: &str) -> bool {
424    match code.parse::<ErrorCode>() {
425        Ok(code) if code.status() == status => code.is_retryable(),
426        Ok(_) => false,
427        Err(_) => matches!(
428            status,
429            StatusCode::REQUEST_TIMEOUT
430                | StatusCode::TOO_MANY_REQUESTS
431                | StatusCode::INTERNAL_SERVER_ERROR
432                | StatusCode::BAD_GATEWAY
433                | StatusCode::SERVICE_UNAVAILABLE
434                | StatusCode::GATEWAY_TIMEOUT
435        ),
436    }
437}
438
439pub(crate) fn server_error_has_no_side_effects(status: StatusCode, code: &str) -> bool {
440    code.parse::<ErrorCode>()
441        .is_ok_and(|code| code.status() == status && code.has_no_side_effects())
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    fn response(status: StatusCode, code: &str) -> ServerError {
449        ServerError::from_api(
450            status,
451            ServerErrorBody {
452                code: code.to_owned(),
453                message: "test".to_owned(),
454            },
455        )
456    }
457
458    #[test]
459    fn error_response_preserves_raw_and_known_codes() {
460        let known = response(StatusCode::NOT_FOUND, "basin_not_found");
461        assert_eq!(known.code, "basin_not_found");
462        assert_eq!(known.message, "test");
463        assert_eq!(known.known_code(), Some(ErrorCode::BasinNotFound));
464        assert!(known.to_string().contains("basin_not_found"));
465
466        let unknown = response(StatusCode::BAD_REQUEST, "introduced_by_a_newer_server");
467        assert_eq!(unknown.known_code(), None);
468        assert_eq!(unknown.code, "introduced_by_a_newer_server");
469    }
470
471    #[test]
472    fn server_classification_fails_closed_on_status_mismatch() {
473        let mismatch = response(StatusCode::INTERNAL_SERVER_ERROR, "rate_limited");
474        assert!(!mismatch.is_retryable());
475        assert!(!mismatch.has_no_side_effects());
476    }
477
478    #[test]
479    fn unknown_codes_retain_retryable_status_fallback() {
480        let unknown = response(StatusCode::SERVICE_UNAVAILABLE, "future_server_error");
481        assert!(unknown.is_retryable());
482        assert!(!unknown.has_no_side_effects());
483    }
484
485    #[test]
486    fn internal_client_errors_preserve_the_failure_stage() {
487        assert!(matches!(
488            ClientError::from(client::HttpError::RequestBuild("bad request".to_owned())),
489            ClientError::RequestBuild(message) if message == "bad request"
490        ));
491        assert!(matches!(
492            ClientError::from(client::HttpError::RequestCompression("encode".to_owned())),
493            ClientError::RequestCompression(message) if message == "encode"
494        ));
495        assert!(matches!(
496            ClientError::from(client::HttpError::ResponseCompression("decode".to_owned())),
497            ClientError::ResponseCompression(message) if message == "decode"
498        ));
499
500        let json_error = serde_json::from_slice::<serde_json::Value>(b"{")
501            .expect_err("invalid JSON should fail");
502        assert!(matches!(
503            ClientError::from(client::HttpError::ResponseDecode(json_error)),
504            ClientError::ResponseDecode(_)
505        ));
506    }
507
508    #[test]
509    fn nested_errors_expose_request_and_server_errors() {
510        let append = AppendError::Request(RequestError::Server(response(
511            StatusCode::CONFLICT,
512            "transaction_conflict",
513        )));
514
515        assert!(append.is_retryable());
516        assert!(append.has_no_side_effects());
517        let request = append.request_error().expect("request error");
518        assert!(matches!(request, RequestError::Server(_)));
519        let server = request.server_error().expect("server error");
520        assert_eq!(server.known_code(), Some(ErrorCode::TransactionConflict));
521    }
522}