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
122fn default_gateway_message(status: u16) -> String {
123 format!("Gateway request failed with status {status}")
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129
130 #[test]
133 fn route_unavailable_is_not_a_json_404() {
134 let plain_404 = map_gateway_error(404, None);
135 let json_404 = map_gateway_error(
136 404,
137 Some(GatewayErrorBody { error: Some("Not found".into()), ..Default::default() }),
138 );
139 assert!(is_route_unavailable(&plain_404), "a plain-text 404 means no such route");
140 assert!(!is_route_unavailable(&json_404), "a JSON 404 means the sandbox is gone");
141 assert!(is_route_unavailable(&map_gateway_error(405, None)));
142 assert!(is_route_unavailable(&map_gateway_error(501, None)));
143 assert!(!is_route_unavailable(&map_gateway_error(500, None)));
144 assert!(!is_route_unavailable(&SolariError::Other("x".into())));
145 }
146
147 #[derive(Debug, PartialEq)]
148 enum Kind {
149 Auth,
150 Plan,
151 Concurrency,
152 NoCapacity,
153 Plain,
154 }
155
156 fn kind(e: &SolariError) -> Kind {
157 match e {
158 SolariError::Auth { .. } => Kind::Auth,
159 SolariError::Plan { .. } => Kind::Plan,
160 SolariError::ConcurrencyLimit { .. } => Kind::Concurrency,
161 SolariError::NoCapacity { .. } => Kind::NoCapacity,
162 SolariError::Gateway { .. } => Kind::Plain,
163 other => panic!("not a gateway error: {other:?}"),
164 }
165 }
166
167 #[test]
169 fn maps_by_code_first() {
170 let cases: &[(&str, u16, Option<&str>, Kind)] = &[
171 ("401", 401, Some("Unauthorized"), Kind::Auth),
172 ("402-plan", 402, Some("FeatureRequiresPlan"), Kind::Plan),
173 ("402-credit", 402, Some("InsufficientCredit"), Kind::Plan),
174 ("403-not-entitled", 403, Some("NotEntitled"), Kind::Plan),
175 ("403-other", 403, Some("Forbidden"), Kind::Plain),
176 ("404", 404, Some("NotFound"), Kind::Plain),
177 ("409-sandbox-stopped", 409, Some("SandboxStopped"), Kind::Plain),
178 ("410-gone", 410, Some("SandboxGone"), Kind::Plain),
179 ("429-concurrency-cap", 429, Some("ConcurrencyLimitExceeded"), Kind::Concurrency),
180 ("429-no-code", 429, None, Kind::Concurrency),
181 ("429-too-many-executions", 429, Some("TooManyExecutions"), Kind::Plain),
182 ("429-object-limit", 429, Some("ObjectLimit"), Kind::Plain),
183 ("500-internal", 500, Some("InternalError"), Kind::Plain),
184 ("501-not-supported", 501, Some("NotSupported"), Kind::Plain),
185 ("503-no-capacity", 503, Some("NoCapacity"), Kind::NoCapacity),
186 ("503-start-timeout", 503, Some("StartTimeout"), Kind::NoCapacity),
187 ("503-no-code", 503, None, Kind::NoCapacity),
188 ("503-sandbox-unavailable", 503, Some("SandboxUnavailable"), Kind::Plain),
189 ("503-store-unavailable", 503, Some("StoreUnavailable"), Kind::Plain),
190 ("503-admission-unavailable", 503, Some("AdmissionUnavailable"), Kind::Plain),
191 ("503-dependency-unavailable", 503, Some("DependencyUnavailable"), Kind::Plain),
192 ("507-insufficient-capacity", 507, Some("InsufficientCapacity"), Kind::Plain),
193 ];
194 for (id, status, code, want) in cases {
195 let body = GatewayErrorBody { code: code.map(str::to_string), ..Default::default() };
196 let e = map_gateway_error(*status, Some(body));
197 assert_eq!(&kind(&e), want, "errors/{id}");
198 assert_eq!(e.status(), Some(*status), "errors/{id}: the status the gateway sent is kept");
199 let kept = match &e {
200 SolariError::Auth { code, .. }
201 | SolariError::Plan { code, .. }
202 | SolariError::ConcurrencyLimit { code, .. }
203 | SolariError::NoCapacity { code, .. }
204 | SolariError::Gateway { code, .. } => code.clone(),
205 _ => unreachable!(),
206 };
207 assert_eq!(kept.as_deref(), *code, "errors/{id}: the gateway's code is kept");
208 }
209 }
210}