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