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            // The client's own concurrency limiter shed this request before it
43            // was sent: the local client is saturated. Surfaced as
44            // service-unavailable (the caller's dependency is momentarily
45            // overloaded), with the cause named in the detail.
46            TransportError::Overloaded => CanonicalError::service_unavailable()
47                .with_detail("client concurrency limit reached (request shed before send)")
48                .create(),
49            // Provider not registered / no live instance: same canonical shape
50            // as a network failure — retryable service-unavailable. Keep the
51            // gear name in the detail so operators can triage which dependency
52            // failed to resolve.
53            TransportError::Unresolved { gear } => CanonicalError::service_unavailable()
54                .with_detail(format!("provider `{gear}` is not resolvable"))
55                .create(),
56            TransportError::Timeout(d) => {
57                CanonicalError::internal(format!("timeout after {d:?}")).create()
58            }
59            TransportError::Serialization(msg) => {
60                CanonicalError::internal(format!("serialization error: {msg}")).create()
61            }
62            // A peer that does not conform to the wire framing is an internal
63            // fault; naming the framing is what makes the detail actionable.
64            TransportError::Framing { framing, source } => CanonicalError::internal(format!(
65                "{} framing error: {source}",
66                framing.media_type()
67            ))
68            .create(),
69            TransportError::UrlBuild(msg) => {
70                CanonicalError::internal(format!("URL build error: {msg}")).create()
71            }
72        }
73    }
74}
75
76fn problem_to_canonical(problem: Problem) -> CanonicalError {
77    // Falls back to 500 if `try_from` fails on a Problem with no status at
78    // all (only reachable from an SSE error event) - the safest guess when
79    // nothing else is known.
80    let status = problem.status.unwrap_or(500);
81    let title = problem.title.clone();
82    let detail = problem.detail.clone();
83    match CanonicalError::try_from(problem) {
84        Ok(err) => err,
85        Err(_) => http_status_to_canonical(status, &format!("{title}: {detail}")),
86    }
87}
88
89fn synth_problem(category: ProblemCategory, detail: &str) -> Problem {
90    Problem {
91        problem_type: format!("gts://{}", category.gts_fragment()),
92        title: category.title().to_owned(),
93        status: Some(category.http_status()),
94        detail: detail.to_owned(),
95        instance: None,
96        trace_id: None,
97        // Carry every field any synthesizable category's context needs.
98        // serde ignores unknown fields, so categories that don't use a given
99        // key (e.g. NotFound ignores `reason`, PermissionDenied ignores the
100        // resource fields) deserialize fine. `reason` is required by
101        // `PermissionDeniedV1`; omitting it made `synth_to_canonical` panic for
102        // 403 / gRPC PermissionDenied.
103        context: serde_json::json!({
104            "resource_type": "unknown",
105            "resource_name": "unknown",
106            "reason": detail,
107        }),
108        error_code: None,
109        error_domain: None,
110    }
111}
112
113#[allow(
114    clippy::expect_used,
115    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."
116)]
117fn synth_to_canonical(category: ProblemCategory, detail: &str) -> CanonicalError {
118    CanonicalError::try_from(synth_problem(category, detail))
119        .expect("synthetic problem_type is always a known canonical GTS URI")
120}
121
122fn http_status_to_canonical(status: u16, body: &str) -> CanonicalError {
123    // `body` is peer-controlled (arbitrary UTF-8); a raw `&body[..200]` byte
124    // slice panics if 200 lands inside a multi-byte character. Floor to the
125    // nearest char boundary at or below 200.
126    let preview: &str = if body.len() > 200 {
127        let cut = (0..=200)
128            .rev()
129            .find(|&i| body.is_char_boundary(i))
130            .unwrap_or(0);
131        &body[..cut]
132    } else {
133        body
134    };
135    match status {
136        401 => CanonicalError::unauthenticated()
137            .with_reason(preview.to_owned())
138            .create(),
139        403 => synth_to_canonical(ProblemCategory::PermissionDenied, preview),
140        404 => synth_to_canonical(ProblemCategory::NotFound, preview),
141        409 => synth_to_canonical(ProblemCategory::AlreadyExists, preview),
142        503 => CanonicalError::service_unavailable().create(),
143        s => CanonicalError::internal(format!("HTTP {s}: {preview}")).create(),
144    }
145}
146
147#[cfg(feature = "grpc-client")]
148fn grpc_code_to_canonical(code: tonic::Code, message: String) -> CanonicalError {
149    use tonic::Code;
150    match code {
151        Code::Unauthenticated => CanonicalError::unauthenticated()
152            .with_reason(message)
153            .create(),
154        Code::Unavailable => CanonicalError::service_unavailable().create(),
155        Code::NotFound => synth_to_canonical(ProblemCategory::NotFound, &message),
156        Code::AlreadyExists => synth_to_canonical(ProblemCategory::AlreadyExists, &message),
157        Code::PermissionDenied => synth_to_canonical(ProblemCategory::PermissionDenied, &message),
158        other => CanonicalError::internal(format!("gRPC {other:?}: {message}")).create(),
159    }
160}
161
162#[cfg(test)]
163#[cfg_attr(coverage_nightly, coverage(off))]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn problem_not_found_preserves_category() {
169        let original = toolkit_canonical_errors::Problem::from_error(
170            &CanonicalError::try_from(synth_problem(ProblemCategory::NotFound, "missing")).unwrap(),
171        )
172        .unwrap();
173        let err: CanonicalError = TransportError::problem(original).into();
174        assert!(matches!(err, CanonicalError::NotFound { .. }));
175    }
176
177    #[test]
178    fn http_404_fallback_yields_not_found() {
179        let err: CanonicalError = TransportError::HttpStatus {
180            status: 404,
181            body: "missing".into(),
182            retry_after: None,
183        }
184        .into();
185        assert!(matches!(err, CanonicalError::NotFound { .. }));
186    }
187
188    #[test]
189    fn http_status_to_canonical_does_not_panic_on_multibyte_char_at_boundary() {
190        // 199 ASCII bytes + a 3-byte UTF-8 char straddling byte 200 — a raw
191        // `&body[..200]` slice would panic since byte 200 falls inside it.
192        let body = format!("{}€", "a".repeat(199));
193        assert_eq!(body.len(), 202);
194        let err: CanonicalError = TransportError::HttpStatus {
195            status: 403,
196            body,
197            retry_after: None,
198        }
199        .into();
200        assert!(matches!(err, CanonicalError::PermissionDenied { .. }));
201    }
202
203    #[test]
204    fn overloaded_maps_to_service_unavailable_with_detail() {
205        // A locally-shed request (concurrency limiter) is surfaced as a
206        // retryable service-unavailable, with the shed named in the detail so
207        // operators can tell it apart from an upstream 503.
208        let err: CanonicalError = TransportError::Overloaded.into();
209        let problem = Problem::from_error(&err).expect("problem from overloaded");
210        assert_eq!(problem.status, Some(503));
211        assert!(
212            problem.detail.contains("concurrency limit reached"),
213            "detail names the shed: {}",
214            problem.detail
215        );
216    }
217
218    #[test]
219    fn http_403_fallback_yields_permission_denied() {
220        let err: CanonicalError = TransportError::HttpStatus {
221            status: 403,
222            body: "nope".into(),
223            retry_after: None,
224        }
225        .into();
226        assert!(matches!(err, CanonicalError::PermissionDenied { .. }));
227    }
228
229    #[test]
230    fn http_409_fallback_yields_already_exists() {
231        let err: CanonicalError = TransportError::HttpStatus {
232            status: 409,
233            body: "dup".into(),
234            retry_after: None,
235        }
236        .into();
237        assert!(matches!(err, CanonicalError::AlreadyExists { .. }));
238    }
239
240    #[cfg(feature = "grpc-client")]
241    #[test]
242    fn grpc_not_found_preserves_category() {
243        let err: CanonicalError = TransportError::Grpc {
244            code: tonic::Code::NotFound,
245            message: "missing".into(),
246        }
247        .into();
248        assert!(matches!(err, CanonicalError::NotFound { .. }));
249    }
250
251    #[cfg(feature = "grpc-client")]
252    #[test]
253    fn grpc_already_exists_preserves_category() {
254        let err: CanonicalError = TransportError::Grpc {
255            code: tonic::Code::AlreadyExists,
256            message: "dup".into(),
257        }
258        .into();
259        assert!(matches!(err, CanonicalError::AlreadyExists { .. }));
260    }
261
262    #[cfg(feature = "grpc-client")]
263    #[test]
264    fn grpc_permission_denied_preserves_category() {
265        let err: CanonicalError = TransportError::Grpc {
266            code: tonic::Code::PermissionDenied,
267            message: "nope".into(),
268        }
269        .into();
270        assert!(matches!(err, CanonicalError::PermissionDenied { .. }));
271    }
272}