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::Unresolved { gear } => CanonicalError::service_unavailable()
47 .with_detail(format!("provider `{gear}` is not resolvable"))
48 .create(),
49 TransportError::Timeout(d) => {
50 CanonicalError::internal(format!("timeout after {d:?}")).create()
51 }
52 TransportError::Serialization(msg) => {
53 CanonicalError::internal(format!("serialization error: {msg}")).create()
54 }
55 TransportError::Framing { framing, source } => CanonicalError::internal(format!(
58 "{} framing error: {source}",
59 framing.media_type()
60 ))
61 .create(),
62 TransportError::UrlBuild(msg) => {
63 CanonicalError::internal(format!("URL build error: {msg}")).create()
64 }
65 }
66 }
67}
68
69fn problem_to_canonical(problem: Problem) -> CanonicalError {
70 let status = problem.status.unwrap_or(500);
74 let title = problem.title.clone();
75 let detail = problem.detail.clone();
76 match CanonicalError::try_from(problem) {
77 Ok(err) => err,
78 Err(_) => http_status_to_canonical(status, &format!("{title}: {detail}")),
79 }
80}
81
82fn synth_problem(category: ProblemCategory, detail: &str) -> Problem {
83 Problem {
84 problem_type: format!("gts://{}", category.gts_fragment()),
85 title: category.title().to_owned(),
86 status: Some(category.http_status()),
87 detail: detail.to_owned(),
88 instance: None,
89 trace_id: None,
90 context: serde_json::json!({
97 "resource_type": "unknown",
98 "resource_name": "unknown",
99 "reason": detail,
100 }),
101 error_code: None,
102 error_domain: None,
103 }
104}
105
106#[allow(
107 clippy::expect_used,
108 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."
109)]
110fn synth_to_canonical(category: ProblemCategory, detail: &str) -> CanonicalError {
111 CanonicalError::try_from(synth_problem(category, detail))
112 .expect("synthetic problem_type is always a known canonical GTS URI")
113}
114
115fn http_status_to_canonical(status: u16, body: &str) -> CanonicalError {
116 let preview: &str = if body.len() > 200 {
120 let cut = (0..=200)
121 .rev()
122 .find(|&i| body.is_char_boundary(i))
123 .unwrap_or(0);
124 &body[..cut]
125 } else {
126 body
127 };
128 match status {
129 401 => CanonicalError::unauthenticated()
130 .with_reason(preview.to_owned())
131 .create(),
132 403 => synth_to_canonical(ProblemCategory::PermissionDenied, preview),
133 404 => synth_to_canonical(ProblemCategory::NotFound, preview),
134 409 => synth_to_canonical(ProblemCategory::AlreadyExists, preview),
135 503 => CanonicalError::service_unavailable().create(),
136 s => CanonicalError::internal(format!("HTTP {s}: {preview}")).create(),
137 }
138}
139
140#[cfg(feature = "grpc-client")]
141fn grpc_code_to_canonical(code: tonic::Code, message: String) -> CanonicalError {
142 use tonic::Code;
143 match code {
144 Code::Unauthenticated => CanonicalError::unauthenticated()
145 .with_reason(message)
146 .create(),
147 Code::Unavailable => CanonicalError::service_unavailable().create(),
148 Code::NotFound => synth_to_canonical(ProblemCategory::NotFound, &message),
149 Code::AlreadyExists => synth_to_canonical(ProblemCategory::AlreadyExists, &message),
150 Code::PermissionDenied => synth_to_canonical(ProblemCategory::PermissionDenied, &message),
151 other => CanonicalError::internal(format!("gRPC {other:?}: {message}")).create(),
152 }
153}
154
155#[cfg(test)]
156#[cfg_attr(coverage_nightly, coverage(off))]
157mod tests {
158 use super::*;
159
160 #[test]
161 fn problem_not_found_preserves_category() {
162 let original = toolkit_canonical_errors::Problem::from_error(
163 &CanonicalError::try_from(synth_problem(ProblemCategory::NotFound, "missing")).unwrap(),
164 )
165 .unwrap();
166 let err: CanonicalError = TransportError::problem(original).into();
167 assert!(matches!(err, CanonicalError::NotFound { .. }));
168 }
169
170 #[test]
171 fn http_404_fallback_yields_not_found() {
172 let err: CanonicalError = TransportError::HttpStatus {
173 status: 404,
174 body: "missing".into(),
175 retry_after: None,
176 }
177 .into();
178 assert!(matches!(err, CanonicalError::NotFound { .. }));
179 }
180
181 #[test]
182 fn http_status_to_canonical_does_not_panic_on_multibyte_char_at_boundary() {
183 let body = format!("{}€", "a".repeat(199));
186 assert_eq!(body.len(), 202);
187 let err: CanonicalError = TransportError::HttpStatus {
188 status: 403,
189 body,
190 retry_after: None,
191 }
192 .into();
193 assert!(matches!(err, CanonicalError::PermissionDenied { .. }));
194 }
195
196 #[test]
197 fn http_403_fallback_yields_permission_denied() {
198 let err: CanonicalError = TransportError::HttpStatus {
199 status: 403,
200 body: "nope".into(),
201 retry_after: None,
202 }
203 .into();
204 assert!(matches!(err, CanonicalError::PermissionDenied { .. }));
205 }
206
207 #[test]
208 fn http_409_fallback_yields_already_exists() {
209 let err: CanonicalError = TransportError::HttpStatus {
210 status: 409,
211 body: "dup".into(),
212 retry_after: None,
213 }
214 .into();
215 assert!(matches!(err, CanonicalError::AlreadyExists { .. }));
216 }
217
218 #[cfg(feature = "grpc-client")]
219 #[test]
220 fn grpc_not_found_preserves_category() {
221 let err: CanonicalError = TransportError::Grpc {
222 code: tonic::Code::NotFound,
223 message: "missing".into(),
224 }
225 .into();
226 assert!(matches!(err, CanonicalError::NotFound { .. }));
227 }
228
229 #[cfg(feature = "grpc-client")]
230 #[test]
231 fn grpc_already_exists_preserves_category() {
232 let err: CanonicalError = TransportError::Grpc {
233 code: tonic::Code::AlreadyExists,
234 message: "dup".into(),
235 }
236 .into();
237 assert!(matches!(err, CanonicalError::AlreadyExists { .. }));
238 }
239
240 #[cfg(feature = "grpc-client")]
241 #[test]
242 fn grpc_permission_denied_preserves_category() {
243 let err: CanonicalError = TransportError::Grpc {
244 code: tonic::Code::PermissionDenied,
245 message: "nope".into(),
246 }
247 .into();
248 assert!(matches!(err, CanonicalError::PermissionDenied { .. }));
249 }
250}