1use crate::types::GatewayErrorBody;
8
9#[derive(Debug, thiserror::Error)]
13#[non_exhaustive]
18pub enum SolariError {
19 #[error("{message}")]
21 Auth { status: u16, message: String, code: Option<String> },
22
23 #[error("{message}")]
26 Plan { status: u16, message: String, code: Option<String> },
27
28 #[error("{message}")]
31 ConcurrencyLimit { status: u16, message: String, code: Option<String> },
32
33 #[error("{message}")]
37 NoCapacity { status: u16, message: String, code: Option<String> },
38
39 #[error("{message}")]
41 Gateway { status: u16, message: String, code: Option<String> },
42
43 #[error("{message}")]
45 Action { method: String, message: String, code: Option<String> },
46
47 #[error("Action \"{method}\" timed out after {timeout_ms}ms")]
49 Timeout { method: String, timeout_ms: u64 },
50
51 #[error("{message}")]
53 Connection { message: String },
54
55 #[error("{message}")]
57 Git { message: String },
58
59 #[error("{0}")]
61 Other(String),
62 #[error("{message}")]
66 Validation { message: String },
67}
68
69impl SolariError {
70 pub(crate) fn connection(msg: impl Into<String>) -> Self {
71 SolariError::Connection { message: msg.into() }
72 }
73
74 pub fn status(&self) -> Option<u16> {
76 match self {
77 SolariError::Auth { status, .. }
78 | SolariError::Plan { status, .. }
79 | SolariError::ConcurrencyLimit { status, .. }
80 | SolariError::NoCapacity { status, .. }
81 | SolariError::Gateway { status, .. } => Some(*status),
82 _ => None,
83 }
84 }
85}
86
87pub(crate) fn map_gateway_error(status: u16, body: Option<GatewayErrorBody>) -> SolariError {
90 let code = body.as_ref().and_then(|b| b.code.clone());
91 let message = body
92 .as_ref()
93 .and_then(|b| b.message.clone().or_else(|| b.error.clone()).or_else(|| b.code.clone()))
94 .unwrap_or_else(|| default_gateway_message(status));
95 match (status, code.as_deref()) {
98 (401, _) => SolariError::Auth { status, message, code },
99 (402, _) => SolariError::Plan { status, message, code },
100 (403, Some("NotEntitled")) => SolariError::Plan { status, message, code },
102 (429, None | Some("ConcurrencyLimitExceeded")) => SolariError::ConcurrencyLimit { status, message, code },
103 (503, None | Some("NoCapacity" | "StartTimeout")) => SolariError::NoCapacity { status, message, code },
104 _ => SolariError::Gateway { status, message, code },
105 }
106}
107
108pub(crate) fn is_route_unavailable(err: &SolariError) -> bool {
116 match err {
117 SolariError::Gateway { status: 404, message, code: None } => *message == default_gateway_message(404),
118 _ => matches!(err.status(), Some(405) | Some(501)),
119 }
120}
121
122pub(crate) fn is_shared_refusal(err: &SolariError) -> bool {
124 match err {
125 SolariError::Action { code: Some(c), .. } if c == "NotAvailableOnSharedIsolation" => true,
126 other => {
127 let m = other.to_string();
128 m.contains("NotAvailableOnSharedIsolation") || m.contains("not available on a shared")
129 }
130 }
131}
132
133fn default_gateway_message(status: u16) -> String {
134 format!("Gateway request failed with status {status}")
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140
141 #[test]
144 fn route_unavailable_is_not_a_json_404() {
145 let plain_404 = map_gateway_error(404, None);
146 let json_404 = map_gateway_error(
147 404,
148 Some(GatewayErrorBody { error: Some("Not found".into()), ..Default::default() }),
149 );
150 assert!(is_route_unavailable(&plain_404), "a plain-text 404 means no such route");
151 assert!(!is_route_unavailable(&json_404), "a JSON 404 means the sandbox is gone");
152 assert!(is_route_unavailable(&map_gateway_error(405, None)));
153 assert!(is_route_unavailable(&map_gateway_error(501, None)));
154 assert!(!is_route_unavailable(&map_gateway_error(500, None)));
155 assert!(!is_route_unavailable(&SolariError::Other("x".into())));
156 }
157
158 #[derive(Debug, PartialEq)]
159 enum Kind {
160 Auth,
161 Plan,
162 Concurrency,
163 NoCapacity,
164 Plain,
165 }
166
167 fn kind(e: &SolariError) -> Kind {
168 match e {
169 SolariError::Auth { .. } => Kind::Auth,
170 SolariError::Plan { .. } => Kind::Plan,
171 SolariError::ConcurrencyLimit { .. } => Kind::Concurrency,
172 SolariError::NoCapacity { .. } => Kind::NoCapacity,
173 SolariError::Gateway { .. } => Kind::Plain,
174 other => panic!("not a gateway error: {other:?}"),
175 }
176 }
177
178 #[test]
180 fn maps_by_code_first() {
181 let cases: &[(&str, u16, Option<&str>, Kind)] = &[
182 ("401", 401, Some("Unauthorized"), Kind::Auth),
183 ("402-plan", 402, Some("FeatureRequiresPlan"), Kind::Plan),
184 ("402-credit", 402, Some("InsufficientCredit"), Kind::Plan),
185 ("403-not-entitled", 403, Some("NotEntitled"), Kind::Plan),
186 ("403-other", 403, Some("Forbidden"), Kind::Plain),
187 ("404", 404, Some("NotFound"), Kind::Plain),
188 ("409-sandbox-stopped", 409, Some("SandboxStopped"), Kind::Plain),
189 ("410-gone", 410, Some("SandboxGone"), Kind::Plain),
190 ("429-concurrency-cap", 429, Some("ConcurrencyLimitExceeded"), Kind::Concurrency),
191 ("429-no-code", 429, None, Kind::Concurrency),
192 ("429-too-many-executions", 429, Some("TooManyExecutions"), Kind::Plain),
193 ("429-object-limit", 429, Some("ObjectLimit"), Kind::Plain),
194 ("500-internal", 500, Some("InternalError"), Kind::Plain),
195 ("501-not-supported", 501, Some("NotSupported"), Kind::Plain),
196 ("503-no-capacity", 503, Some("NoCapacity"), Kind::NoCapacity),
197 ("503-start-timeout", 503, Some("StartTimeout"), Kind::NoCapacity),
198 ("503-no-code", 503, None, Kind::NoCapacity),
199 ("503-sandbox-unavailable", 503, Some("SandboxUnavailable"), Kind::Plain),
200 ("503-store-unavailable", 503, Some("StoreUnavailable"), Kind::Plain),
201 ("503-admission-unavailable", 503, Some("AdmissionUnavailable"), Kind::Plain),
202 ("503-dependency-unavailable", 503, Some("DependencyUnavailable"), Kind::Plain),
203 ("507-insufficient-capacity", 507, Some("InsufficientCapacity"), Kind::Plain),
204 ];
205 for (id, status, code, want) in cases {
206 let body = GatewayErrorBody { code: code.map(str::to_string), ..Default::default() };
207 let e = map_gateway_error(*status, Some(body));
208 assert_eq!(&kind(&e), want, "errors/{id}");
209 assert_eq!(e.status(), Some(*status), "errors/{id}: the status the gateway sent is kept");
210 let kept = match &e {
211 SolariError::Auth { code, .. }
212 | SolariError::Plan { code, .. }
213 | SolariError::ConcurrencyLimit { code, .. }
214 | SolariError::NoCapacity { code, .. }
215 | SolariError::Gateway { code, .. } => code.clone(),
216 _ => unreachable!(),
217 };
218 assert_eq!(kept.as_deref(), *code, "errors/{id}: the gateway's code is kept");
219 }
220 }
221}