Skip to main content

steam_client/
error.rs

1//! Steam client error types.
2
3use steam_auth::EAuthSessionGuardType;
4use steam_cm_provider::CmError;
5use steam_enums::EResult;
6use thiserror::Error;
7
8/// Errors that can occur when using the Steam client.
9#[derive(Error, Debug)]
10#[non_exhaustive]
11pub enum SteamError {
12    /// Steam returned an error result.
13    #[error("Steam error: {0:?}")]
14    SteamResult(EResult),
15
16    /// Connection error.
17    #[error("Connection error: {0}")]
18    ConnectionError(String),
19
20    /// Already logged on.
21    #[error("Already logged on")]
22    AlreadyLoggedOn,
23
24    /// Already connecting.
25    #[error("Already connecting")]
26    AlreadyConnecting,
27
28    /// Not logged on.
29    #[error("Not logged on")]
30    NotLoggedOn,
31
32    /// Not connected.
33    #[error("Not connected")]
34    NotConnected,
35
36    /// Invalid credentials.
37    #[error("Invalid credentials")]
38    InvalidCredentials,
39
40    /// Steam Guard authentication required.
41    ///
42    /// This error is returned when password authentication requires a Steam
43    /// Guard code. The `guard_type` indicates what kind of code is needed:
44    /// - `EmailCode`: A code sent to the account's email
45    /// - `DeviceCode`: A TOTP code from the Steam mobile app
46    /// - `DeviceConfirmation`: Approval via the Steam mobile app
47    #[error("Steam Guard required: {guard_type:?}")]
48    SteamGuardRequired {
49        /// Type of Steam Guard verification needed.
50        guard_type: EAuthSessionGuardType,
51        /// Email domain hint (e.g., "g****.com") if guard_type is EmailCode.
52        email_domain: Option<String>,
53    },
54
55    /// Two-factor authentication required (legacy).
56    #[error("Two-factor authentication required")]
57    TwoFactorRequired,
58
59    /// Invalid token.
60    #[error("Invalid token: {0}")]
61    InvalidToken(String),
62
63    /// Network error.
64    #[error("Network error: {0}")]
65    NetworkError(std::io::Error),
66
67    /// Timeout.
68    #[error("Operation timed out")]
69    Timeout,
70
71    /// Response timed out.
72    #[error("Response timed out")]
73    ResponseTimeout,
74
75    /// Deserialization failed.
76    #[error("Deserialization failed")]
77    DeserializationFailed,
78
79    /// Protocol error.
80    #[error("Protocol error: {0}")]
81    ProtocolError(String),
82
83    /// Bad response from Steam.
84    ///
85    /// This error is returned when Steam returns a malformed response or one
86    /// that violates expectations (e.g. missing SteamID in logon response).
87    #[error("Bad response: {message}")]
88    BadResponse {
89        /// Human-readable error message.
90        message: String,
91        /// The EMsg that triggered this error (if known).
92        emsg: Option<steam_enums::EMsg>,
93        /// The raw bytes that failed to parse (truncated for display).
94        raw_bytes: Option<Vec<u8>>,
95    },
96
97    /// Session error.
98    #[error("Session error: {0}")]
99    SessionError(#[from] steam_auth::SessionError),
100
101    /// Not implemented yet.
102    #[error("Not implemented: {0}")]
103    NotImplemented(String),
104
105    /// HTTP/Reqwest error (transparent — preserves `source()` chain).
106    #[error(transparent)]
107    Reqwest(#[from] reqwest::Error),
108
109    /// WebSocket transport error (transparent — preserves `source()` chain).
110    #[error(transparent)]
111    WebSocket(#[from] tokio_tungstenite::tungstenite::Error),
112
113    /// Protobuf decode error (transparent — preserves `source()` chain).
114    #[error(transparent)]
115    Decode(#[from] prost::DecodeError),
116
117    // Transitional escape hatch — prefer typed variants for new code.
118    /// Other error.
119    #[error("{0}")]
120    Other(String),
121}
122
123impl SteamError {
124    /// Returns the EResult if this is a Steam result error.
125    pub fn eresult(&self) -> Option<EResult> {
126        match self {
127            SteamError::SteamResult(result) => Some(*result),
128            _ => None,
129        }
130    }
131
132    /// Returns true if the error is a transient error that might be resolved by
133    /// retrying.
134    ///
135    /// Matches Node.js behavior for handling:
136    /// - Fail
137    /// - ServiceUnavailable
138    /// - TryAnotherCM
139    /// - NoConnection (in logoff context)
140    pub fn is_retryable(&self) -> bool {
141        match self {
142            SteamError::SteamResult(result) => matches!(result, EResult::Fail | EResult::ServiceUnavailable | EResult::TryAnotherCM | EResult::NoConnection),
143            SteamError::NetworkError(_) | SteamError::Timeout => true,
144            _ => false,
145        }
146    }
147
148    /// Create a BadResponse error with just a message (backwards-compatible
149    /// shorthand).
150    pub fn bad_response(message: impl Into<String>) -> Self {
151        SteamError::BadResponse { message: message.into(), emsg: None, raw_bytes: None }
152    }
153
154    /// Create a BadResponse error with full context.
155    pub fn bad_response_with_context(message: impl Into<String>, emsg: Option<steam_enums::EMsg>, raw_bytes: Option<Vec<u8>>) -> Self {
156        SteamError::BadResponse { message: message.into(), emsg, raw_bytes }
157    }
158}
159
160impl From<CmError> for SteamError {
161    fn from(e: CmError) -> Self {
162        match e {
163            CmError::Network(s) => SteamError::NetworkError(std::io::Error::other(s)),
164            CmError::Protocol(s) => SteamError::ProtocolError(s),
165            CmError::ApiError(status, msg) => SteamError::ProtocolError(format!("Steam API error (status {}): {}", status, msg)),
166            CmError::InvalidResponse(s) => SteamError::bad_response(s),
167            CmError::Connection(s) => SteamError::ConnectionError(s),
168            CmError::CacheError(s) => SteamError::Other(format!("Cache error: {}", s)),
169            CmError::Timeout => SteamError::Timeout,
170            CmError::NoServers => SteamError::ConnectionError("No CM servers available".into()),
171            CmError::Io(e) => SteamError::NetworkError(e),
172            CmError::Json(e) => SteamError::ProtocolError(format!("JSON error: {}", e)),
173            CmError::Other(s) => SteamError::Other(s),
174        }
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use std::error::Error as _;
182
183    /// Verifies that the `#[from] prost::DecodeError` machinery produces a
184    /// `SteamError::Decode` whose payload is the original `prost::DecodeError`
185    /// and whose `Display` impl is transparent (delegates to the inner error).
186    ///
187    /// Note: because the variant is `#[error(transparent)]`, `source()` on the
188    /// outer `SteamError` returns the *inner* error's `source()` — and a
189    /// `prost::DecodeError` has no underlying source itself. So we verify
190    /// chain-correctness by downcasting via `Error::source` on a wrapper, or
191    /// by matching the variant directly.
192    #[test]
193    fn prost_decode_error_preserves_source_chain() {
194        let decode_err = prost::DecodeError::new("invalid wire format");
195        let expected_display = decode_err.to_string();
196
197        let steam_err: SteamError = decode_err.into();
198
199        // Must be the typed variant.
200        let inner = match &steam_err {
201            SteamError::Decode(e) => e,
202            other => panic!("expected SteamError::Decode, got {:?}", other),
203        };
204
205        // Transparent Display: outer error's message must equal inner's.
206        assert_eq!(steam_err.to_string(), expected_display);
207        assert_eq!(inner.to_string(), expected_display);
208
209        // Wrap the SteamError as a source of another error to exercise the
210        // `source()` chain — proves callers using `anyhow`/`eyre` can walk
211        // down to the underlying `prost::DecodeError`.
212        #[derive(Debug)]
213        struct Wrap(SteamError);
214        impl std::fmt::Display for Wrap {
215            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
216                write!(f, "wrapped")
217            }
218        }
219        impl std::error::Error for Wrap {
220            fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
221                Some(&self.0)
222            }
223        }
224        let wrapped = Wrap(steam_err);
225        let src = wrapped.source().expect("wrapper exposes SteamError as source");
226        // The source must downcast to SteamError (since `transparent`
227        // forwards through, but the outer Wrap returns the SteamError
228        // itself, not its inner).
229        assert!(src.downcast_ref::<SteamError>().is_some(), "source should downcast to SteamError");
230    }
231
232    /// Verifies that decoding malformed bytes via `prost::Message::decode`
233    /// flows through `#[from]` into `SteamError::Decode` end-to-end and
234    /// preserves the underlying error's message through transparent Display.
235    #[test]
236    fn prost_message_decode_failure_converts_via_from() {
237        use prost::Message;
238
239        let bad: &[u8] = &[0xff, 0xff, 0xff];
240        let result: Result<String, prost::DecodeError> = String::decode(bad);
241        let decode_err = result.expect_err("decoding malformed bytes must fail");
242        let expected_display = decode_err.to_string();
243
244        let steam_err: SteamError = decode_err.into();
245        assert!(matches!(steam_err, SteamError::Decode(_)));
246        // Transparent Display delegation proves the `#[from]` machinery
247        // actually wraps the original error rather than stringifying it.
248        assert_eq!(steam_err.to_string(), expected_display);
249    }
250}