silicon_commit_client/
error.rs1use reqwest::StatusCode;
5use reqwest::header::HeaderMap;
6use serde::Serialize;
7use serde_json::Value;
8use std::fmt;
9
10const MAX_DETAILS_BYTES: usize = 4096;
12
13#[derive(Debug, thiserror::Error)]
15#[non_exhaustive]
16pub enum Error {
17 #[error("The Commit API URL `{url}` cannot be used: {reason}")]
19 InvalidUrl {
20 url: String,
22 reason: &'static str,
24 },
25 #[error("{0}")]
27 Invalid(String),
28 #[error("Could not reach the Commit API at {base}: {cause}")]
30 Transport {
31 base: String,
33 cause: String,
35 #[source]
37 source: reqwest::Error,
38 },
39 #[error("{0}")]
41 Api(Box<ApiError>),
42 #[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 served: String,
49 },
50 #[error("The Commit API response exceeds the 8 MiB limit, so it was not read.")]
52 ResponseTooLarge,
53 #[error("The Commit API response is not the JSON this client expects: {0}")]
55 Decode(#[source] serde_json::Error),
56 #[error("{0}")]
60 Accounts(#[from] silicon_accounts_client::Error),
61}
62
63impl Error {
64 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 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 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 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 pub fn as_api(&self) -> Option<&ApiError> {
108 match self {
109 Self::Api(api) => Some(api),
110 _ => None,
111 }
112 }
113
114 pub fn is_unauthenticated(&self) -> bool {
117 matches!(self, Self::Api(api) if api.status == 401)
118 }
119
120 pub fn is_sign_in_refused(&self) -> bool {
123 matches!(self, Self::Accounts(error) if error.code() == "invalid_grant")
124 }
125
126 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#[derive(Clone, Debug, PartialEq, Serialize)]
142#[non_exhaustive]
143pub struct ApiError {
144 pub status: u16,
146 pub code: String,
148 pub message: String,
150 #[serde(skip_serializing_if = "Option::is_none")]
152 pub hint: Option<String>,
153 #[serde(skip_serializing_if = "Option::is_none")]
155 pub details: Option<Value>,
156 #[serde(skip_serializing_if = "Option::is_none")]
158 pub request_id: Option<String>,
159 #[serde(skip_serializing_if = "Option::is_none")]
161 pub retry_after_seconds: Option<u64>,
162}
163
164impl ApiError {
165 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 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
260pub(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}