Skip to main content

silicon_commit_client/
error.rs

1//! Typed errors. Each one says what went wrong and why; API errors keep the service's
2//! `{"error":{"code","message","hint","details","request_id"}}` envelope.
3
4use reqwest::StatusCode;
5use reqwest::header::HeaderMap;
6use serde::Serialize;
7use serde_json::Value;
8use std::fmt;
9
10/// Largest `details` value kept from an error body (serialized bytes).
11const MAX_DETAILS_BYTES: usize = 4096;
12
13/// Everything that can go wrong in this crate.
14#[derive(Debug, thiserror::Error)]
15#[non_exhaustive]
16pub enum Error {
17    /// The Commit API URL cannot be used; nothing was sent.
18    #[error("The Commit API URL `{url}` cannot be used: {reason}")]
19    InvalidUrl {
20        /// The URL as given.
21        url: String,
22        /// Why it was refused.
23        reason: &'static str,
24    },
25    /// A value passed to the client was refused before anything was sent.
26    #[error("{0}")]
27    Invalid(String),
28    /// The request never got an answer (DNS, connection, TLS, timeout).
29    #[error("Could not reach the Commit API at {base}: {cause}")]
30    Transport {
31        /// The API origin.
32        base: String,
33        /// The innermost cause, without URLs or credentials.
34        cause: String,
35        /// The transport error (its URL is removed).
36        #[source]
37        source: reqwest::Error,
38    },
39    /// The Commit API answered with an error.
40    #[error("{0}")]
41    Api(Box<ApiError>),
42    /// The Commit API answered with a contract this client does not speak.
43    #[error(
44        "The Commit API answered with contract {served}, but this client speaks contract 2 (Silicon Accounts sign-in). Update silicon-commit-client or the commit CLI."
45    )]
46    UnsupportedContract {
47        /// The `X-Commit-API-Version` the API answered with.
48        served: String,
49    },
50    /// The response body was larger than 8 MiB, so it was not read.
51    #[error("The Commit API response exceeds the 8 MiB limit, so it was not read.")]
52    ResponseTooLarge,
53    /// The response body was not the JSON this client expects.
54    #[error("The Commit API response is not the JSON this client expects: {0}")]
55    Decode(#[source] serde_json::Error),
56    /// Silicon Accounts refused or failed a sign-in step (device code, short-lived
57    /// token, refresh or revocation). The inner error carries the code, a precise message
58    /// and a hint.
59    #[error("{0}")]
60    Accounts(#[from] silicon_accounts_client::Error),
61}
62
63impl Error {
64    /// Stable machine-readable code: the API's `error.code`, the Silicon Accounts code
65    /// (`invalid_grant`, `access_denied`, `expired_token`, …), or a client-side code.
66    pub fn code(&self) -> &str {
67        match self {
68            Self::InvalidUrl { .. } => "invalid_url",
69            Self::Invalid(_) => "invalid_input",
70            Self::Transport { .. } => "connection_failed",
71            Self::Api(api) => &api.code,
72            Self::UnsupportedContract { .. } => "unsupported_contract",
73            Self::ResponseTooLarge => "response_too_large",
74            Self::Decode(_) => "unexpected_response",
75            Self::Accounts(error) => error.code(),
76        }
77    }
78
79    /// HTTP status of the failed response, if there was one.
80    pub fn status(&self) -> Option<u16> {
81        match self {
82            Self::Api(api) => Some(api.status),
83            Self::Accounts(error) => error.status(),
84            _ => None,
85        }
86    }
87
88    /// The request id of the failed response, if any (quote it when reporting a bug).
89    pub fn request_id(&self) -> Option<&str> {
90        match self {
91            Self::Api(api) => api.request_id.as_deref(),
92            Self::Accounts(error) => error.request_id(),
93            _ => None,
94        }
95    }
96
97    /// What to do next, when known.
98    pub fn hint(&self) -> Option<String> {
99        match self {
100            Self::Api(api) => api.hint.clone(),
101            Self::Accounts(error) => error.hint(),
102            _ => None,
103        }
104    }
105
106    /// The API error envelope, when this is an API error.
107    pub fn as_api(&self) -> Option<&ApiError> {
108        match self {
109            Self::Api(api) => Some(api),
110            _ => None,
111        }
112    }
113
114    /// True when the Commit API refused the credential (HTTP 401): refresh the access
115    /// token (or sign in again) and retry.
116    pub fn is_unauthenticated(&self) -> bool {
117        matches!(self, Self::Api(api) if api.status == 401)
118    }
119
120    /// True when Silicon Accounts refused a refresh token or short-lived token for good
121    /// (`invalid_grant`): the sign-in ended or the token cannot be used, so sign in again.
122    pub fn is_sign_in_refused(&self) -> bool {
123        matches!(self, Self::Accounts(error) if error.code() == "invalid_grant")
124    }
125
126    /// True when nobody answered or the answer was a temporary failure (5xx, 429): the
127    /// request may or may not have been applied.
128    pub fn is_transient(&self) -> bool {
129        match self {
130            Self::Transport { .. } => true,
131            Self::Api(api) => api.status >= 500 || api.status == 429,
132            Self::Accounts(error) => {
133                error.is_transport() || error.status().is_some_and(|s| s >= 500 || s == 429)
134            }
135            _ => false,
136        }
137    }
138}
139
140/// The Commit API's error envelope plus transport metadata.
141#[derive(Clone, Debug, PartialEq, Serialize)]
142#[non_exhaustive]
143pub struct ApiError {
144    /// HTTP status code.
145    pub status: u16,
146    /// Stable machine-readable code, e.g. `validation_failed`, `silicon_not_reachable`.
147    pub code: String,
148    /// What went wrong and why.
149    pub message: String,
150    /// What to do next, when the service gave one.
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub hint: Option<String>,
153    /// Structured details the service marks safe to show (field errors, limits).
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub details: Option<Value>,
156    /// The request id (`X-Request-Id` or `error.request_id`).
157    #[serde(skip_serializing_if = "Option::is_none")]
158    pub request_id: Option<String>,
159    /// Seconds to wait before retrying (`Retry-After`).
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub retry_after_seconds: Option<u64>,
162}
163
164impl ApiError {
165    /// Creates an error value (useful in tests and mocks).
166    pub fn new(status: u16, code: impl Into<String>, message: impl Into<String>) -> Self {
167        Self {
168            status,
169            code: code.into(),
170            message: message.into(),
171            hint: None,
172            details: None,
173            request_id: None,
174            retry_after_seconds: None,
175        }
176    }
177
178    /// Builds the error for a non-2xx answer. Only the documented envelope fields are
179    /// kept; any other body (a proxy page, the wrong URL) is dropped, never echoed.
180    pub(crate) fn from_response(status: StatusCode, headers: &HeaderMap, body: &[u8]) -> Self {
181        let header = |name: &str| {
182            headers
183                .get(name)
184                .and_then(|v| v.to_str().ok())
185                .map(str::trim)
186                .filter(|v| !v.is_empty())
187                .map(str::to_owned)
188        };
189        let envelope = serde_json::from_slice::<Value>(body)
190            .ok()
191            .and_then(|v| v.get("error").cloned())
192            .filter(Value::is_object);
193        let text = |key: &str| {
194            envelope
195                .as_ref()
196                .and_then(|e| e.get(key))
197                .and_then(Value::as_str)
198                .map(str::trim)
199                .filter(|v| !v.is_empty())
200                .map(str::to_owned)
201        };
202        let code = text("code")
203            .filter(|c| c.len() <= 128 && c.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_'))
204            .unwrap_or_else(|| format!("http_{}", status.as_u16()));
205        let reason = status.canonical_reason().unwrap_or("error");
206        let message = text("message").unwrap_or_else(|| {
207            if envelope.is_some() {
208                format!("The Commit API answered HTTP {} {reason} ({code}).", status.as_u16())
209            } else {
210                format!(
211                    "The Commit API answered HTTP {} {reason} without its JSON error body; a proxy answered, or the URL does not point at the Commit API.",
212                    status.as_u16()
213                )
214            }
215        });
216        let details = envelope
217            .as_ref()
218            .and_then(|e| e.get("details"))
219            .filter(|d| !d.is_null())
220            .filter(|d| serde_json::to_vec(d).is_ok_and(|b| b.len() <= MAX_DETAILS_BYTES))
221            .cloned();
222        Self {
223            status: status.as_u16(),
224            code,
225            message,
226            hint: text("hint"),
227            details,
228            request_id: header("x-request-id").or_else(|| text("request_id")),
229            retry_after_seconds: header("retry-after").and_then(|v| v.parse().ok()),
230        }
231    }
232}
233
234impl fmt::Display for ApiError {
235    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
236        write!(f, "{} (HTTP {} {}", self.message, self.status, self.code)?;
237        if let Some(id) = &self.request_id {
238            write!(f, ", request ID {id}")?;
239        }
240        f.write_str(")")?;
241        if let Some(details) = &self.details {
242            write!(f, " Details: {details}")?;
243        }
244        if let Some(seconds) = self.retry_after_seconds {
245            write!(f, " Retry after {seconds} s.")?;
246        }
247        if let Some(hint) = &self.hint {
248            write!(f, " Hint: {hint}")?;
249        }
250        Ok(())
251    }
252}
253
254impl From<ApiError> for Error {
255    fn from(value: ApiError) -> Self {
256        Self::Api(Box::new(value))
257    }
258}
259
260/// The innermost message of an error chain (e.g. `Connection refused (os error 61)`).
261pub(crate) fn root_cause(error: &(dyn std::error::Error + 'static)) -> String {
262    let mut current = error;
263    while let Some(next) = current.source() {
264        current = next;
265    }
266    current.to_string()
267}