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