Skip to main content

toolkit_contract/runtime/
canonical.rs

1//! Conversion from [`TransportError`] into [`toolkit_canonical_errors::CanonicalError`].
2//!
3//! Lives in `toolkit-contract` (not in `toolkit-canonical-errors`) so the
4//! canonical-errors crate stays a leaf in the workspace dep graph. Gated
5//! behind the `canonical-errors` feature.
6//!
7//! # Mapping policy
8//!
9//! When the peer participates in the canonical-errors envelope (RFC 9457
10//! `Problem` with a `gts://...` `type` URI, either inline on the HTTP
11//! response body or attached as the `x-toolkit-problem-bin` gRPC trailer),
12//! the typed `CanonicalError::*` variant is recovered via
13//! [`toolkit_canonical_errors::CanonicalError::try_from(Problem)`]. Resource
14//! info (`resource_type`, `resource_name`) is pulled out of
15//! `Problem.context` so callers can `matches!(err, CanonicalError::NotFound
16//! { .. })` after the conversion.
17//!
18//! Fallbacks for peers that don't speak the envelope:
19//! - [`TransportError::HttpStatus`]: resource-scoped statuses (404 / 409 /
20//!   403) construct the matching variant with `resource_type = "unknown"`
21//!   and `resource_name = "unknown"` via a synthetic `Problem`.
22//! - [`TransportError::Grpc`]: resource-scoped codes (`NotFound`,
23//!   `AlreadyExists`, `PermissionDenied`) likewise construct the matching
24//!   variant with synthetic "unknown" resource info.
25//! - Other categories (Internal, Unavailable, Unauthenticated, ...) map
26//!   directly via the canonical category mapping.
27
28use 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            // Provider not registered / no live instance: same canonical shape
43            // as a network failure — retryable service-unavailable. Keep the
44            // gear name in the detail so operators can triage which dependency
45            // failed to resolve.
46            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            // A peer that does not conform to the wire framing is an internal
56            // fault; naming the framing is what makes the detail actionable.
57            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    // Falls back to 500 if `try_from` fails on a Problem with no status at
71    // all (only reachable from an SSE error event) - the safest guess when
72    // nothing else is known.
73    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        // Carry every field any synthesizable category's context needs.
91        // serde ignores unknown fields, so categories that don't use a given
92        // key (e.g. NotFound ignores `reason`, PermissionDenied ignores the
93        // resource fields) deserialize fine. `reason` is required by
94        // `PermissionDeniedV1`; omitting it made `synth_to_canonical` panic for
95        // 403 / gRPC PermissionDenied.
96        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    // `body` is peer-controlled (arbitrary UTF-8); a raw `&body[..200]` byte
117    // slice panics if 200 lands inside a multi-byte character. Floor to the
118    // nearest char boundary at or below 200.
119    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        // 199 ASCII bytes + a 3-byte UTF-8 char straddling byte 200 — a raw
184        // `&body[..200]` slice would panic since byte 200 falls inside it.
185        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}