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}
336
337impl AppendError {
338    /// Whether retrying the operation is safe or sensible.
339    pub fn is_retryable(&self) -> bool {
340        matches!(self, Self::Request(error) if error.is_retryable())
341    }
342
343    /// Whether retrying the operation cannot duplicate a mutation.
344    pub fn has_no_side_effects(&self) -> bool {
345        match self {
346            Self::Request(error) => error.has_no_side_effects(),
347            Self::ConditionFailed(_) => true,
348        }
349    }
350
351    /// Return the underlying request error, if present.
352    pub fn request_error(&self) -> Option<&RequestError> {
353        match self {
354            Self::Request(error) => Some(error),
355            Self::ConditionFailed(_) => None,
356        }
357    }
358}
359
360impl From<ApiError> for AppendError {
361    fn from(error: ApiError) -> Self {
362        match error {
363            ApiError::AppendConditionFailed(condition) => Self::ConditionFailed(condition.into()),
364            other => Self::Request(other.into()),
365        }
366    }
367}
368
369/// Errors from producer operations.
370#[derive(Debug, Clone, thiserror::Error)]
371#[non_exhaustive]
372pub enum ProducerError {
373    /// An append-session error encountered while producing records.
374    #[error(transparent)]
375    Append(#[from] AppendSessionError),
376    /// Producer input validation failed before an append was attempted.
377    #[error(transparent)]
378    Validation(#[from] ValidationError),
379    /// The producer was already closed.
380    #[error("producer already closed")]
381    ProducerClosed,
382    /// The producer is closing.
383    #[error("producer is closing")]
384    ProducerClosing,
385    /// The producer was dropped without being closed.
386    #[error("producer dropped without calling close")]
387    ProducerDropped,
388}
389
390impl ProducerError {
391    /// Whether retrying the operation is safe or sensible.
392    pub fn is_retryable(&self) -> bool {
393        match self {
394            Self::Append(error) => error.is_retryable(),
395            Self::Validation(_)
396            | Self::ProducerClosed
397            | Self::ProducerClosing
398            | Self::ProducerDropped => false,
399        }
400    }
401
402    /// Whether retrying the operation cannot duplicate a mutation.
403    pub fn has_no_side_effects(&self) -> bool {
404        match self {
405            Self::Append(error) => error.has_no_side_effects(),
406            Self::Validation(_) | Self::ProducerClosed | Self::ProducerClosing => true,
407            Self::ProducerDropped => false,
408        }
409    }
410
411    /// Return the underlying request error, if present.
412    pub fn request_error(&self) -> Option<&RequestError> {
413        match self {
414            Self::Append(error) => error.request_error(),
415            Self::Validation(_)
416            | Self::ProducerClosed
417            | Self::ProducerClosing
418            | Self::ProducerDropped => None,
419        }
420    }
421}
422
423/// An error returned by an S2 server.
424#[derive(Debug, Clone, thiserror::Error)]
425#[error("{code}: {message}")]
426#[non_exhaustive]
427pub struct ServerError {
428    /// HTTP status returned by the server.
429    pub status: StatusCode,
430    /// Error code.
431    pub code: String,
432    /// Error message.
433    pub message: String,
434}
435
436impl ServerError {
437    pub(crate) fn from_api(status: StatusCode, response: ServerErrorBody) -> Self {
438        Self {
439            status,
440            code: response.code,
441            message: response.message,
442        }
443    }
444
445    /// Return the server error code when it is recognized by this SDK version.
446    ///
447    /// The raw [`code`](Self::code) remains available so callers can preserve and report codes
448    /// introduced by newer servers.
449    pub fn known_code(&self) -> Option<ErrorCode> {
450        self.code.parse().ok()
451    }
452
453    /// Whether retrying the request is safe or sensible for this server error.
454    pub fn is_retryable(&self) -> bool {
455        server_error_is_retryable(self.status, &self.code)
456    }
457
458    /// Whether retrying the request cannot duplicate a mutation.
459    pub fn has_no_side_effects(&self) -> bool {
460        server_error_has_no_side_effects(self.status, &self.code)
461    }
462}
463
464pub(crate) fn server_error_is_retryable(status: StatusCode, code: &str) -> bool {
465    match code.parse::<ErrorCode>() {
466        Ok(code) if code.status() == status => code.is_retryable(),
467        Ok(_) => false,
468        Err(_) => matches!(
469            status,
470            StatusCode::REQUEST_TIMEOUT
471                | StatusCode::TOO_MANY_REQUESTS
472                | StatusCode::INTERNAL_SERVER_ERROR
473                | StatusCode::BAD_GATEWAY
474                | StatusCode::SERVICE_UNAVAILABLE
475                | StatusCode::GATEWAY_TIMEOUT
476        ),
477    }
478}
479
480pub(crate) fn server_error_has_no_side_effects(status: StatusCode, code: &str) -> bool {
481    code.parse::<ErrorCode>()
482        .is_ok_and(|code| code.status() == status && code.has_no_side_effects())
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488
489    fn response(status: StatusCode, code: &str) -> ServerError {
490        ServerError::from_api(
491            status,
492            ServerErrorBody {
493                code: code.to_owned(),
494                message: "test".to_owned(),
495            },
496        )
497    }
498
499    #[test]
500    fn error_response_preserves_raw_and_known_codes() {
501        let known = response(StatusCode::NOT_FOUND, "basin_not_found");
502        assert_eq!(known.code, "basin_not_found");
503        assert_eq!(known.message, "test");
504        assert_eq!(known.known_code(), Some(ErrorCode::BasinNotFound));
505        assert!(known.to_string().contains("basin_not_found"));
506
507        let unknown = response(StatusCode::BAD_REQUEST, "introduced_by_a_newer_server");
508        assert_eq!(unknown.known_code(), None);
509        assert_eq!(unknown.code, "introduced_by_a_newer_server");
510    }
511
512    #[test]
513    fn server_classification_fails_closed_on_status_mismatch() {
514        let mismatch = response(StatusCode::INTERNAL_SERVER_ERROR, "rate_limited");
515        assert!(!mismatch.is_retryable());
516        assert!(!mismatch.has_no_side_effects());
517    }
518
519    #[test]
520    fn unknown_codes_retain_retryable_status_fallback() {
521        let unknown = response(StatusCode::SERVICE_UNAVAILABLE, "future_server_error");
522        assert!(unknown.is_retryable());
523        assert!(!unknown.has_no_side_effects());
524    }
525
526    #[test]
527    fn internal_client_errors_preserve_the_failure_stage() {
528        assert!(matches!(
529            ClientError::from(client::HttpError::RequestBuild("bad request".to_owned())),
530            ClientError::RequestBuild(message) if message == "bad request"
531        ));
532        assert!(matches!(
533            ClientError::from(client::HttpError::RequestCompression("encode".to_owned())),
534            ClientError::RequestCompression(message) if message == "encode"
535        ));
536        assert!(matches!(
537            ClientError::from(client::HttpError::ResponseCompression("decode".to_owned())),
538            ClientError::ResponseCompression(message) if message == "decode"
539        ));
540
541        let json_error = serde_json::from_slice::<serde_json::Value>(b"{")
542            .expect_err("invalid JSON should fail");
543        assert!(matches!(
544            ClientError::from(client::HttpError::ResponseDecode(json_error)),
545            ClientError::ResponseDecode(_)
546        ));
547    }
548
549    #[test]
550    fn nested_errors_expose_request_and_server_errors() {
551        let append = AppendError::Request(RequestError::Server(response(
552            StatusCode::CONFLICT,
553            "transaction_conflict",
554        )));
555
556        assert!(append.is_retryable());
557        assert!(append.has_no_side_effects());
558        let request = append.request_error().expect("request error");
559        assert!(matches!(request, RequestError::Server(_)));
560        let server = request.server_error().expect("server error");
561        assert_eq!(server.known_code(), Some(ErrorCode::TransactionConflict));
562    }
563}