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_timeout() {
126        // The h2 keep-alive timing out fails requests sent on the dead connection.
127        Some(ClientError::Timeout)
128    } else if hyper_err.is_incomplete_message() || hyper_err.is_closed() {
129        // `is_closed` covers a request dispatched onto a pooled connection
130        // that the server had already shut down.
131        Some(ClientError::ConnectionClosedEarly(err_msg))
132    } else if hyper_err.is_canceled() {
133        Some(ClientError::RequestCanceled(err_msg))
134    } else if source_err::<h2::Error>(err).is_some_and(|e| {
135        e.is_io() || e.is_go_away() || e.reason() == Some(h2::Reason::REFUSED_STREAM)
136    }) {
137        // An I/O failure ends streaming bodies without tripping any hyper marker above.
138        // A remote GOAWAY ends streams dispatched onto a connection the server is
139        // gracefully shutting down.
140        Some(ClientError::ConnectionClosedEarly(err_msg))
141    } else {
142        None
143    }
144}
145
146fn classify_io_source(err: &client::HttpError, err_msg: &str) -> Option<ClientError> {
147    let io_err = source_err::<std::io::Error>(err)?;
148    let err_msg = format!("{io_err} -> {err_msg}");
149    Some(match io_err.kind() {
150        std::io::ErrorKind::UnexpectedEof => ClientError::UnexpectedEof(err_msg),
151        // h2 surfaces a stream cut short by connection shutdown as a broken pipe.
152        std::io::ErrorKind::BrokenPipe => ClientError::ConnectionClosedEarly(err_msg),
153        std::io::ErrorKind::ConnectionReset => ClientError::ConnectionReset(err_msg),
154        std::io::ErrorKind::ConnectionAborted => ClientError::ConnectionAborted(err_msg),
155        std::io::ErrorKind::ConnectionRefused => ClientError::ConnectionRefused(err_msg),
156        _ => return None,
157    })
158}
159
160fn source_err<T: std::error::Error + 'static>(err: &dyn std::error::Error) -> Option<&T> {
161    let mut source = err.source();
162    while let Some(err) = source {
163        if let Some(err) = err.downcast_ref::<T>() {
164            return Some(err);
165        }
166        source = err.source();
167    }
168    None
169}
170
171/// Why an append condition check failed.
172#[derive(Debug, Clone, thiserror::Error)]
173#[non_exhaustive]
174pub enum AppendConditionFailed {
175    /// Fencing token did not match. Contains the expected fencing token.
176    #[error("fencing token mismatch, expected: {0}")]
177    FencingTokenMismatch(FencingToken),
178    /// Sequence number did not match. Contains the expected sequence number.
179    #[error("sequence number mismatch, expected: {0}")]
180    SeqNumMismatch(u64),
181}
182
183impl From<api::stream::AppendConditionFailed> for AppendConditionFailed {
184    fn from(value: api::stream::AppendConditionFailed) -> Self {
185        match value {
186            api::stream::AppendConditionFailed::FencingTokenMismatch(token) => {
187                Self::FencingTokenMismatch(FencingToken::from_server(token.to_string()))
188            }
189            api::stream::AppendConditionFailed::SeqNumMismatch(seq) => Self::SeqNumMismatch(seq),
190        }
191    }
192}
193
194/// Errors that can be returned by any network request.
195#[derive(Debug, Clone, thiserror::Error)]
196#[non_exhaustive]
197pub enum RequestError {
198    /// A client-side error.
199    #[error(transparent)]
200    Client(#[from] ClientError),
201    /// An error returned by the server.
202    #[error(transparent)]
203    Server(#[from] ServerError),
204    /// The access token could not be used as an HTTP header value.
205    #[error("malformed access token: {0}")]
206    MalformedAccessToken(String),
207    #[cfg(feature = "_hidden")]
208    #[doc(hidden)]
209    #[error("access token provider failed: {0}")]
210    AccessTokenProvider(crate::types::AccessTokenProviderError),
211    /// Input validation failed.
212    #[error(transparent)]
213    Validation(#[from] ValidationError),
214}
215
216impl RequestError {
217    /// Whether retrying the operation is safe or sensible.
218    pub fn is_retryable(&self) -> bool {
219        match self {
220            Self::Client(error) => error.is_retryable(),
221            Self::Server(error) => error.is_retryable(),
222            #[cfg(feature = "_hidden")]
223            Self::AccessTokenProvider(error) => error.is_retryable(),
224            Self::MalformedAccessToken(_) | Self::Validation(_) => false,
225        }
226    }
227
228    /// Whether retrying the operation cannot duplicate a mutation.
229    pub fn has_no_side_effects(&self) -> bool {
230        match self {
231            Self::Client(error) => error.has_no_side_effects(),
232            Self::Server(error) => error.has_no_side_effects(),
233            #[cfg(feature = "_hidden")]
234            Self::AccessTokenProvider(_) => true,
235            Self::MalformedAccessToken(_) | Self::Validation(_) => true,
236        }
237    }
238
239    /// Return the server error, if present.
240    pub fn server_error(&self) -> Option<&ServerError> {
241        match self {
242            Self::Server(error) => Some(error),
243            _ => None,
244        }
245    }
246
247    pub(crate) fn is_authentication_error(&self) -> bool {
248        matches!(
249            self,
250            Self::Server(error)
251                if error.status == StatusCode::UNAUTHORIZED && error.code == "authn"
252        )
253    }
254
255    pub(crate) fn is_server_draining(&self) -> bool {
256        matches!(
257            self,
258            Self::Server(error)
259                if error.status == StatusCode::SERVICE_UNAVAILABLE
260                    && error.code == "server_draining"
261        )
262    }
263}
264
265impl From<ApiError> for RequestError {
266    fn from(error: ApiError) -> Self {
267        match error {
268            ApiError::Client(error) => Self::Client(error),
269            ApiError::ProtoDecode(error) => {
270                Self::Client(ClientError::ResponseDecode(error.to_string()))
271            }
272            ApiError::TerminalDecode(error) => {
273                Self::Client(ClientError::SessionProtocol(error.to_string()))
274            }
275            ApiError::MalformedAccessToken(error) => Self::MalformedAccessToken(error),
276            #[cfg(feature = "_hidden")]
277            ApiError::AccessTokenProvider(error) => Self::AccessTokenProvider(error),
278            ApiError::Compression(error) => {
279                Self::Client(ClientError::ResponseCompression(error.to_string()))
280            }
281            ApiError::Server(status, response) => {
282                Self::Server(ServerError::from_api(status, response))
283            }
284            other => Self::Client(ClientError::Other(other.to_string())),
285        }
286    }
287}
288
289/// Errors returned by unary read operations.
290#[derive(Debug, Clone, thiserror::Error)]
291#[non_exhaustive]
292pub enum ReadError {
293    /// A network request error.
294    #[error(transparent)]
295    Request(#[from] RequestError),
296    /// The requested position has not been written.
297    #[error("read from an unwritten position. current tail: {0}")]
298    ReadUnwritten(StreamPosition),
299}
300
301impl ReadError {
302    /// Whether retrying the operation is safe or sensible.
303    pub fn is_retryable(&self) -> bool {
304        matches!(self, Self::Request(error) if error.is_retryable())
305    }
306
307    /// Return the underlying request error, if present.
308    pub fn request_error(&self) -> Option<&RequestError> {
309        match self {
310            Self::Request(error) => Some(error),
311            Self::ReadUnwritten(_) => None,
312        }
313    }
314}
315
316impl From<ApiError> for ReadError {
317    fn from(error: ApiError) -> Self {
318        match error {
319            ApiError::ReadUnwritten(tail) => Self::ReadUnwritten(tail.tail.into()),
320            other => Self::Request(other.into()),
321        }
322    }
323}
324
325/// Errors returned by unary append operations.
326#[derive(Debug, Clone, thiserror::Error)]
327#[non_exhaustive]
328pub enum AppendError {
329    /// A network request error.
330    #[error(transparent)]
331    Request(#[from] RequestError),
332    /// The append condition did not match.
333    #[error(transparent)]
334    ConditionFailed(#[from] AppendConditionFailed),
335    /// The final attempt failed definitively, but an earlier attempt may have taken effect,
336    /// so the entire append operation is indeterminate.
337    #[error(
338        "append may have taken effect in an earlier attempt; final attempt failed: {final_attempt_error}"
339    )]
340    IndefiniteFailure {
341        /// The definite error returned by the final attempt.
342        #[source]
343        final_attempt_error: Box<Self>,
344    },
345}
346
347impl AppendError {
348    /// Whether retrying the operation is safe or sensible.
349    pub fn is_retryable(&self) -> bool {
350        match self {
351            Self::Request(error) => error.is_retryable(),
352            Self::ConditionFailed(_) => false,
353            Self::IndefiniteFailure {
354                final_attempt_error,
355            } => final_attempt_error.is_retryable(),
356        }
357    }
358
359    /// Whether retrying the operation cannot duplicate a mutation.
360    pub fn has_no_side_effects(&self) -> bool {
361        match self {
362            Self::Request(error) => error.has_no_side_effects(),
363            Self::ConditionFailed(_) => true,
364            Self::IndefiniteFailure { .. } => false,
365        }
366    }
367
368    /// Return the underlying request error, if present.
369    pub fn request_error(&self) -> Option<&RequestError> {
370        match self {
371            Self::Request(error) => Some(error),
372            Self::ConditionFailed(_) => None,
373            Self::IndefiniteFailure {
374                final_attempt_error,
375            } => final_attempt_error.request_error(),
376        }
377    }
378}
379
380impl From<ApiError> for AppendError {
381    fn from(error: ApiError) -> Self {
382        match error {
383            ApiError::AppendConditionFailed(condition) => Self::ConditionFailed(condition.into()),
384            ApiError::IndefiniteFailure {
385                final_attempt_error,
386            } => Self::IndefiniteFailure {
387                final_attempt_error: Box::new((*final_attempt_error).into()),
388            },
389            other => Self::Request(other.into()),
390        }
391    }
392}
393
394/// Errors from producer operations.
395#[derive(Debug, Clone, thiserror::Error)]
396#[non_exhaustive]
397pub enum ProducerError {
398    /// An append-session error encountered while producing records.
399    #[error(transparent)]
400    Append(#[from] AppendSessionError),
401    /// Producer input validation failed before an append was attempted.
402    #[error(transparent)]
403    Validation(#[from] ValidationError),
404    /// The producer was already closed.
405    #[error("producer already closed")]
406    ProducerClosed,
407    /// The producer is closing.
408    #[error("producer is closing")]
409    ProducerClosing,
410    /// The producer was dropped without being closed.
411    #[error("producer dropped without calling close")]
412    ProducerDropped,
413}
414
415impl ProducerError {
416    /// Whether retrying the operation is safe or sensible.
417    pub fn is_retryable(&self) -> bool {
418        match self {
419            Self::Append(error) => error.is_retryable(),
420            Self::Validation(_)
421            | Self::ProducerClosed
422            | Self::ProducerClosing
423            | Self::ProducerDropped => false,
424        }
425    }
426
427    /// Whether retrying the operation cannot duplicate a mutation.
428    pub fn has_no_side_effects(&self) -> bool {
429        match self {
430            Self::Append(error) => error.has_no_side_effects(),
431            Self::Validation(_) | Self::ProducerClosed | Self::ProducerClosing => true,
432            Self::ProducerDropped => false,
433        }
434    }
435
436    /// Return the underlying request error, if present.
437    pub fn request_error(&self) -> Option<&RequestError> {
438        match self {
439            Self::Append(error) => error.request_error(),
440            Self::Validation(_)
441            | Self::ProducerClosed
442            | Self::ProducerClosing
443            | Self::ProducerDropped => None,
444        }
445    }
446}
447
448/// An error returned by an S2 server.
449#[derive(Debug, Clone, thiserror::Error)]
450#[error("{code}: {message}")]
451#[non_exhaustive]
452pub struct ServerError {
453    /// HTTP status returned by the server.
454    pub status: StatusCode,
455    /// Error code.
456    pub code: String,
457    /// Error message.
458    pub message: String,
459}
460
461impl ServerError {
462    pub(crate) fn from_api(status: StatusCode, response: ServerErrorBody) -> Self {
463        Self {
464            status,
465            code: response.code,
466            message: response.message,
467        }
468    }
469
470    /// Return the server error code when it is recognized by this SDK version.
471    ///
472    /// The raw [`code`](Self::code) remains available so callers can preserve and report codes
473    /// introduced by newer servers.
474    pub fn known_code(&self) -> Option<ErrorCode> {
475        self.code.parse().ok()
476    }
477
478    /// Whether retrying the request is safe or sensible for this server error.
479    pub fn is_retryable(&self) -> bool {
480        server_error_is_retryable(self.status, &self.code)
481    }
482
483    /// Whether retrying the request cannot duplicate a mutation.
484    pub fn has_no_side_effects(&self) -> bool {
485        server_error_has_no_side_effects(self.status, &self.code)
486    }
487}
488
489pub(crate) fn server_error_is_retryable(status: StatusCode, code: &str) -> bool {
490    match code.parse::<ErrorCode>() {
491        Ok(code) if code.status() == status => code.is_retryable(),
492        Ok(_) => false,
493        Err(_) => matches!(
494            status,
495            StatusCode::REQUEST_TIMEOUT
496                | StatusCode::TOO_MANY_REQUESTS
497                | StatusCode::INTERNAL_SERVER_ERROR
498                | StatusCode::BAD_GATEWAY
499                | StatusCode::SERVICE_UNAVAILABLE
500                | StatusCode::GATEWAY_TIMEOUT
501        ),
502    }
503}
504
505pub(crate) fn server_error_has_no_side_effects(status: StatusCode, code: &str) -> bool {
506    code.parse::<ErrorCode>()
507        .is_ok_and(|code| code.status() == status && code.has_no_side_effects())
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513
514    fn response(status: StatusCode, code: &str) -> ServerError {
515        ServerError::from_api(
516            status,
517            ServerErrorBody {
518                code: code.to_owned(),
519                message: "test".to_owned(),
520            },
521        )
522    }
523
524    #[test]
525    fn error_response_preserves_raw_and_known_codes() {
526        let known = response(StatusCode::NOT_FOUND, "basin_not_found");
527        assert_eq!(known.code, "basin_not_found");
528        assert_eq!(known.message, "test");
529        assert_eq!(known.known_code(), Some(ErrorCode::BasinNotFound));
530        assert!(known.to_string().contains("basin_not_found"));
531
532        let unknown = response(StatusCode::BAD_REQUEST, "introduced_by_a_newer_server");
533        assert_eq!(unknown.known_code(), None);
534        assert_eq!(unknown.code, "introduced_by_a_newer_server");
535    }
536
537    #[test]
538    fn server_classification_fails_closed_on_status_mismatch() {
539        let mismatch = response(StatusCode::INTERNAL_SERVER_ERROR, "rate_limited");
540        assert!(!mismatch.is_retryable());
541        assert!(!mismatch.has_no_side_effects());
542    }
543
544    #[test]
545    fn unknown_codes_retain_retryable_status_fallback() {
546        let unknown = response(StatusCode::SERVICE_UNAVAILABLE, "future_server_error");
547        assert!(unknown.is_retryable());
548        assert!(!unknown.has_no_side_effects());
549    }
550
551    #[test]
552    fn internal_client_errors_preserve_the_failure_stage() {
553        assert!(matches!(
554            ClientError::from(client::HttpError::RequestBuild("bad request".to_owned())),
555            ClientError::RequestBuild(message) if message == "bad request"
556        ));
557        assert!(matches!(
558            ClientError::from(client::HttpError::RequestCompression("encode".to_owned())),
559            ClientError::RequestCompression(message) if message == "encode"
560        ));
561        assert!(matches!(
562            ClientError::from(client::HttpError::ResponseCompression("decode".to_owned())),
563            ClientError::ResponseCompression(message) if message == "decode"
564        ));
565
566        let json_error = serde_json::from_slice::<serde_json::Value>(b"{")
567            .expect_err("invalid JSON should fail");
568        assert!(matches!(
569            ClientError::from(client::HttpError::ResponseDecode(json_error)),
570            ClientError::ResponseDecode(_)
571        ));
572    }
573
574    #[test]
575    fn nested_errors_expose_request_and_server_errors() {
576        let append = AppendError::Request(RequestError::Server(response(
577            StatusCode::CONFLICT,
578            "transaction_conflict",
579        )));
580
581        assert!(append.is_retryable());
582        assert!(append.has_no_side_effects());
583        let request = append.request_error().expect("request error");
584        assert!(matches!(request, RequestError::Server(_)));
585        let server = request.server_error().expect("server error");
586        assert_eq!(server.known_code(), Some(ErrorCode::TransactionConflict));
587    }
588}