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            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        // Carry every field any synthesizable category's context needs.
84        // serde ignores unknown fields, so categories that don't use a given
85        // key (e.g. NotFound ignores `reason`, PermissionDenied ignores the
86        // resource fields) deserialize fine. `reason` is required by
87        // `PermissionDeniedV1`; omitting it made `synth_to_canonical` panic for
88        // 403 / gRPC PermissionDenied.
89        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    // `body` is peer-controlled (arbitrary UTF-8); a raw `&body[..200]` byte
110    // slice panics if 200 lands inside a multi-byte character. Floor to the
111    // nearest char boundary at or below 200.
112    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        // 199 ASCII bytes + a 3-byte UTF-8 char straddling byte 200 — a raw
177        // `&body[..200]` slice would panic since byte 200 falls inside it.
178        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}