toolkit_contract/runtime/
canonical.rs1use toolkit_canonical_errors::{CanonicalError, Problem, ProblemCategory};
29
30use crate::runtime::transport_error::TransportError;
31
32impl From<TransportError> for CanonicalError {
33 fn from(err: TransportError) -> Self {
34 match err {
35 TransportError::Problem { problem, .. } => problem_to_canonical(*problem),
36 TransportError::HttpStatus { status, body, .. } => {
37 http_status_to_canonical(status, &body)
38 }
39 #[cfg(feature = "grpc-client")]
40 TransportError::Grpc { code, message } => grpc_code_to_canonical(code, message),
41 TransportError::Network(_msg) => CanonicalError::service_unavailable().create(),
42 TransportError::Overloaded => CanonicalError::service_unavailable()
47 .with_detail("client concurrency limit reached (request shed before send)")
48 .create(),
49 TransportError::Unresolved { gear } => CanonicalError::service_unavailable()
54 .with_detail(format!("provider `{gear}` is not resolvable"))
55 .create(),
56 TransportError::Timeout(d) => {
57 CanonicalError::internal(format!("timeout after {d:?}")).create()
58 }
59 TransportError::Serialization(msg) => {
60 CanonicalError::internal(format!("serialization error: {msg}")).create()
61 }
62 TransportError::Framing { framing, source } => CanonicalError::internal(format!(
65 "{} framing error: {source}",
66 framing.media_type()
67 ))
68 .create(),
69 TransportError::UrlBuild(msg) => {
70 CanonicalError::internal(format!("URL build error: {msg}")).create()
71 }
72 }
73 }
74}
75
76fn problem_to_canonical(problem: Problem) -> CanonicalError {
77 let status = problem.status.unwrap_or(500);
81 let title = problem.title.clone();
82 let detail = problem.detail.clone();
83 match CanonicalError::try_from(problem) {
84 Ok(err) => err,
85 Err(_) => http_status_to_canonical(status, &format!("{title}: {detail}")),
86 }
87}
88
89fn synth_problem(category: ProblemCategory, detail: &str) -> Problem {
90 Problem {
91 problem_type: format!("gts://{}", category.gts_fragment()),
92 title: category.title().to_owned(),
93 status: Some(category.http_status()),
94 detail: detail.to_owned(),
95 instance: None,
96 trace_id: None,
97 context: serde_json::json!({
104 "resource_type": "unknown",
105 "resource_name": "unknown",
106 "reason": detail,
107 }),
108 error_code: None,
109 error_domain: None,
110 }
111}
112
113#[allow(
114 clippy::expect_used,
115 reason = "synth_problem unconditionally constructs problem_type from ProblemCategory::canonical_type(), which is the canonical GTS URI registry — CanonicalError::try_from cannot fail for any input synth_problem can produce."
116)]
117fn synth_to_canonical(category: ProblemCategory, detail: &str) -> CanonicalError {
118 CanonicalError::try_from(synth_problem(category, detail))
119 .expect("synthetic problem_type is always a known canonical GTS URI")
120}
121
122fn http_status_to_canonical(status: u16, body: &str) -> CanonicalError {
123 let preview: &str = if body.len() > 200 {
127 let cut = (0..=200)
128 .rev()
129 .find(|&i| body.is_char_boundary(i))
130 .unwrap_or(0);
131 &body[..cut]
132 } else {
133 body
134 };
135 match status {
136 401 => CanonicalError::unauthenticated()
137 .with_reason(preview.to_owned())
138 .create(),
139 403 => synth_to_canonical(ProblemCategory::PermissionDenied, preview),
140 404 => synth_to_canonical(ProblemCategory::NotFound, preview),
141 409 => synth_to_canonical(ProblemCategory::AlreadyExists, preview),
142 503 => CanonicalError::service_unavailable().create(),
143 s => CanonicalError::internal(format!("HTTP {s}: {preview}")).create(),
144 }
145}
146
147#[cfg(feature = "grpc-client")]
148fn grpc_code_to_canonical(code: tonic::Code, message: String) -> CanonicalError {
149 use tonic::Code;
150 match code {
151 Code::Unauthenticated => CanonicalError::unauthenticated()
152 .with_reason(message)
153 .create(),
154 Code::Unavailable => CanonicalError::service_unavailable().create(),
155 Code::NotFound => synth_to_canonical(ProblemCategory::NotFound, &message),
156 Code::AlreadyExists => synth_to_canonical(ProblemCategory::AlreadyExists, &message),
157 Code::PermissionDenied => synth_to_canonical(ProblemCategory::PermissionDenied, &message),
158 other => CanonicalError::internal(format!("gRPC {other:?}: {message}")).create(),
159 }
160}
161
162#[cfg(test)]
163#[cfg_attr(coverage_nightly, coverage(off))]
164mod tests {
165 use super::*;
166
167 #[test]
168 fn problem_not_found_preserves_category() {
169 let original = toolkit_canonical_errors::Problem::from_error(
170 &CanonicalError::try_from(synth_problem(ProblemCategory::NotFound, "missing")).unwrap(),
171 )
172 .unwrap();
173 let err: CanonicalError = TransportError::problem(original).into();
174 assert!(matches!(err, CanonicalError::NotFound { .. }));
175 }
176
177 #[test]
178 fn http_404_fallback_yields_not_found() {
179 let err: CanonicalError = TransportError::HttpStatus {
180 status: 404,
181 body: "missing".into(),
182 retry_after: None,
183 }
184 .into();
185 assert!(matches!(err, CanonicalError::NotFound { .. }));
186 }
187
188 #[test]
189 fn http_status_to_canonical_does_not_panic_on_multibyte_char_at_boundary() {
190 let body = format!("{}€", "a".repeat(199));
193 assert_eq!(body.len(), 202);
194 let err: CanonicalError = TransportError::HttpStatus {
195 status: 403,
196 body,
197 retry_after: None,
198 }
199 .into();
200 assert!(matches!(err, CanonicalError::PermissionDenied { .. }));
201 }
202
203 #[test]
204 fn overloaded_maps_to_service_unavailable_with_detail() {
205 let err: CanonicalError = TransportError::Overloaded.into();
209 let problem = Problem::from_error(&err).expect("problem from overloaded");
210 assert_eq!(problem.status, Some(503));
211 assert!(
212 problem.detail.contains("concurrency limit reached"),
213 "detail names the shed: {}",
214 problem.detail
215 );
216 }
217
218 #[test]
219 fn http_403_fallback_yields_permission_denied() {
220 let err: CanonicalError = TransportError::HttpStatus {
221 status: 403,
222 body: "nope".into(),
223 retry_after: None,
224 }
225 .into();
226 assert!(matches!(err, CanonicalError::PermissionDenied { .. }));
227 }
228
229 #[test]
230 fn http_409_fallback_yields_already_exists() {
231 let err: CanonicalError = TransportError::HttpStatus {
232 status: 409,
233 body: "dup".into(),
234 retry_after: None,
235 }
236 .into();
237 assert!(matches!(err, CanonicalError::AlreadyExists { .. }));
238 }
239
240 #[cfg(feature = "grpc-client")]
241 #[test]
242 fn grpc_not_found_preserves_category() {
243 let err: CanonicalError = TransportError::Grpc {
244 code: tonic::Code::NotFound,
245 message: "missing".into(),
246 }
247 .into();
248 assert!(matches!(err, CanonicalError::NotFound { .. }));
249 }
250
251 #[cfg(feature = "grpc-client")]
252 #[test]
253 fn grpc_already_exists_preserves_category() {
254 let err: CanonicalError = TransportError::Grpc {
255 code: tonic::Code::AlreadyExists,
256 message: "dup".into(),
257 }
258 .into();
259 assert!(matches!(err, CanonicalError::AlreadyExists { .. }));
260 }
261
262 #[cfg(feature = "grpc-client")]
263 #[test]
264 fn grpc_permission_denied_preserves_category() {
265 let err: CanonicalError = TransportError::Grpc {
266 code: tonic::Code::PermissionDenied,
267 message: "nope".into(),
268 }
269 .into();
270 assert!(matches!(err, CanonicalError::PermissionDenied { .. }));
271 }
272}