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    // Falls back to 500 if `try_from` fails on a Problem with no status at
67    // all (only reachable from an SSE error event) - the safest guess when
68    // nothing else is known.
69    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        // Carry every field any synthesizable category's context needs.
87        // serde ignores unknown fields, so categories that don't use a given
88        // key (e.g. NotFound ignores `reason`, PermissionDenied ignores the
89        // resource fields) deserialize fine. `reason` is required by
90        // `PermissionDeniedV1`; omitting it made `synth_to_canonical` panic for
91        // 403 / gRPC PermissionDenied.
92        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    // `body` is peer-controlled (arbitrary UTF-8); a raw `&body[..200]` byte
113    // slice panics if 200 lands inside a multi-byte character. Floor to the
114    // nearest char boundary at or below 200.
115    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        // 199 ASCII bytes + a 3-byte UTF-8 char straddling byte 200 — a raw
180        // `&body[..200]` slice would panic since byte 200 falls inside it.
181        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}