Skip to main content

franken_snowflake_sqlapi/
status.rs

1//! The SQL API response-status state machine.
2//!
3//! HTTP status codes are *distinct protocol signals*, never interchangeable. The
4//! single most common connector bug is conflating them — treating a `429`
5//! (overloaded) as a query status, or a `408` (statement timeout) as a generic
6//! failure. [`ResponseClass`] makes each a separate, exhaustively-matched state,
7//! and the lifecycle bead acts on it. This module is pure classification: no IO.
8
9use franken_snowflake_core::outcome::OutcomeKind;
10
11/// The classification of a SQL API HTTP response by status code.
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13pub enum ResponseClass {
14    /// `200`: completed; body is a [`crate::response::ResultSet`].
15    Completed,
16    /// `202`: still running / accepted async; body is a
17    /// [`crate::response::QueryStatus`]. Poll again.
18    Running,
19    /// `408`: statement exceeded `STATEMENT_TIMEOUT_IN_SECONDS`; body is a
20    /// [`crate::response::QueryFailureStatus`]. A typed timeout, not a SQL error.
21    StatementTimeout,
22    /// `422`: SQL compilation/execution failure; body is a
23    /// [`crate::response::QueryFailureStatus`].
24    StatementFailed,
25    /// `429`: server overloaded / rate limited. Back off and retry; `Retry-After`
26    /// is **not** guaranteed. Never a query-status code.
27    RateLimited,
28    /// Any other status (e.g. `5xx` transport error), carried verbatim.
29    Other(u16),
30}
31
32impl ResponseClass {
33    /// Classify a raw HTTP status code.
34    #[must_use]
35    pub const fn from_status(code: u16) -> Self {
36        match code {
37            200 => Self::Completed,
38            202 => Self::Running,
39            408 => Self::StatementTimeout,
40            422 => Self::StatementFailed,
41            429 => Self::RateLimited,
42            other => Self::Other(other),
43        }
44    }
45
46    /// Whether this state means "poll the handle again" (`202` only).
47    #[must_use]
48    pub const fn should_poll(self) -> bool {
49        matches!(self, Self::Running)
50    }
51
52    /// Whether this state is transient and should be retried under backoff
53    /// (`429`; `5xx` transport). `408`/`422` are terminal failures, not retries.
54    #[must_use]
55    pub const fn is_retryable(self) -> bool {
56        matches!(self, Self::RateLimited | Self::Other(500..=599))
57    }
58
59    /// Whether this state terminates the statement lifecycle (success or a typed
60    /// failure). `Running`/`RateLimited` are non-terminal.
61    #[must_use]
62    pub const fn is_terminal(self) -> bool {
63        matches!(
64            self,
65            Self::Completed | Self::StatementTimeout | Self::StatementFailed
66        )
67    }
68
69    /// The envelope [`OutcomeKind`] for a terminal state, or `None` while the
70    /// statement is still in flight (`Running`/`RateLimited`/non-5xx `Other`).
71    #[must_use]
72    pub const fn terminal_outcome(self) -> Option<OutcomeKind> {
73        match self {
74            Self::Completed => Some(OutcomeKind::Success),
75            Self::StatementTimeout => Some(OutcomeKind::Timeout),
76            Self::StatementFailed => Some(OutcomeKind::Error),
77            Self::Running | Self::RateLimited | Self::Other(_) => None,
78        }
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    #[test]
87    fn status_codes_map_to_distinct_states() {
88        assert_eq!(ResponseClass::from_status(200), ResponseClass::Completed);
89        assert_eq!(ResponseClass::from_status(202), ResponseClass::Running);
90        assert_eq!(
91            ResponseClass::from_status(408),
92            ResponseClass::StatementTimeout
93        );
94        assert_eq!(
95            ResponseClass::from_status(422),
96            ResponseClass::StatementFailed
97        );
98        assert_eq!(ResponseClass::from_status(429), ResponseClass::RateLimited);
99        assert_eq!(ResponseClass::from_status(503), ResponseClass::Other(503));
100    }
101
102    #[test]
103    fn poll_retry_and_terminal_are_not_conflated() {
104        // 202 polls but is neither retryable-by-backoff nor terminal.
105        assert!(ResponseClass::Running.should_poll());
106        assert!(!ResponseClass::Running.is_retryable());
107        assert!(!ResponseClass::Running.is_terminal());
108
109        // 429 retries under backoff but never polls and is never terminal.
110        assert!(ResponseClass::RateLimited.is_retryable());
111        assert!(!ResponseClass::RateLimited.should_poll());
112        assert!(!ResponseClass::RateLimited.is_terminal());
113
114        // 408 is a terminal *timeout*, distinct from a 422 *error*.
115        assert!(ResponseClass::StatementTimeout.is_terminal());
116        assert!(!ResponseClass::StatementTimeout.is_retryable());
117        assert_eq!(
118            ResponseClass::StatementTimeout.terminal_outcome(),
119            Some(OutcomeKind::Timeout)
120        );
121        assert_eq!(
122            ResponseClass::StatementFailed.terminal_outcome(),
123            Some(OutcomeKind::Error)
124        );
125    }
126
127    #[test]
128    fn completed_is_success_and_5xx_retries() {
129        assert_eq!(
130            ResponseClass::Completed.terminal_outcome(),
131            Some(OutcomeKind::Success)
132        );
133        assert!(ResponseClass::from_status(500).is_retryable());
134        assert!(!ResponseClass::from_status(404).is_retryable());
135        assert_eq!(ResponseClass::Running.terminal_outcome(), None);
136    }
137}