Skip to main content

ferrobridge_term/
error.rs

1// SPDX-FileCopyrightText: Cadasto B.V.
2// SPDX-License-Identifier: BUSL-1.1
3
4//! What can go wrong on the wire, as types that carry the upstream answer.
5//!
6//! A refusal a terminology operation expresses in its own `out` parameters is
7//! an outcome of that call (`crate::outcome`), never an error. Everything here
8//! is the other half, and every one of these carries the upstream status and
9//! body rather than an empty value.
10
11use crate::config::TokenFailure;
12use crate::config::WireVersion;
13use http::StatusCode;
14use url::Url;
15
16/// The `tx-issue-type` code system of the FHIR tools implementation guide
17/// (<https://build.fhir.org/ig/FHIR/fhir-tools-ig/CodeSystem-tx-issue-type.html>).
18pub const TX_ISSUE_TYPE: &str = "http://hl7.org/fhir/tools/CodeSystem/tx-issue-type";
19
20/// The terminology-specific classification of one issue.
21///
22/// A terminology server states why a call failed in machine-readable form as a
23/// `details.coding` from [`TX_ISSUE_TYPE`], beside the `issue.code` that every
24/// `OperationOutcome` carries.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct TxIssue {
27    /// The code, for example `not-found` or `invalid-code`.
28    pub code: String,
29    /// The display the server sent with the code, when it sent one.
30    pub display: Option<String>,
31}
32
33/// One issue of an `OperationOutcome`
34/// (<https://hl7.org/fhir/R4/operationoutcome.html>).
35///
36/// `severity` and `code` stay as the server spelled them: the raw body is kept
37/// beside this value in [`UpstreamError`], and a code outside the FHIR value
38/// set would otherwise cost the whole diagnostic.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct Issue {
41    /// `issue.severity`, one of `fatal`, `error`, `warning`, `information`.
42    pub severity: String,
43    /// `issue.code`, a code of the FHIR `IssueType` value set.
44    pub code: String,
45    /// `issue.details.text`, when the server sent one.
46    pub details: Option<String>,
47    /// `issue.diagnostics`, when the server sent one.
48    pub diagnostics: Option<String>,
49    /// The [`TX_ISSUE_TYPE`] coding of `issue.details.coding`, when one is
50    /// present.
51    pub tx_issue_type: Option<TxIssue>,
52}
53
54/// A decoded `OperationOutcome`.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct Outcome {
57    /// The issues, in the order the server sent them.
58    pub issues: Vec<Issue>,
59}
60
61impl Outcome {
62    /// Returns every [`TX_ISSUE_TYPE`] coding the outcome carries.
63    pub fn tx_issues(&self) -> impl Iterator<Item = &TxIssue> {
64        self.issues
65            .iter()
66            .filter_map(|issue| issue.tx_issue_type.as_ref())
67    }
68
69    /// Returns whether any issue carries the [`TX_ISSUE_TYPE`] code `code`.
70    #[must_use]
71    pub fn has_tx_issue(&self, code: &str) -> bool {
72        self.tx_issues().any(|issue| issue.code == code)
73    }
74}
75
76/// A non-success answer from the terminology server, with what it said.
77#[derive(Debug, Clone)]
78pub struct UpstreamError {
79    /// The HTTP status the server answered with.
80    status: StatusCode,
81    /// The response body, as received.
82    body: String,
83    /// The decoded `OperationOutcome`, when the body was one.
84    outcome: Option<Outcome>,
85}
86
87impl UpstreamError {
88    /// Returns the upstream answer `status` and `body` describe, read in
89    /// `version`.
90    ///
91    /// A failed operation answers "an `OperationOutcome` resource with error
92    /// details" (<https://hl7.org/fhir/R4/operations.html>), so the body is
93    /// decoded as one; a body that is not one is a legal answer from a server
94    /// that failed before the operation ran, and the raw body stays either
95    /// way.
96    #[must_use]
97    pub fn new(version: WireVersion, status: StatusCode, body: String) -> Self {
98        let outcome = crate::wire::outcome(version, &body).ok();
99        Self {
100            status,
101            body,
102            outcome,
103        }
104    }
105
106    /// Returns the HTTP status the server answered with.
107    #[must_use]
108    pub fn status(&self) -> StatusCode {
109        self.status
110    }
111
112    /// Returns the response body, as received.
113    #[must_use]
114    pub fn body(&self) -> &str {
115        &self.body
116    }
117
118    /// Returns the decoded `OperationOutcome`, when the body was one.
119    #[must_use]
120    pub fn outcome(&self) -> Option<&Outcome> {
121        self.outcome.as_ref()
122    }
123
124    /// Returns every [`TX_ISSUE_TYPE`] coding the answer carries.
125    pub fn tx_issues(&self) -> impl Iterator<Item = &TxIssue> {
126        self.outcome.iter().flat_map(Outcome::tx_issues)
127    }
128
129    /// Returns whether any issue carries the [`TX_ISSUE_TYPE`] code `code`.
130    #[must_use]
131    pub fn has_tx_issue(&self, code: &str) -> bool {
132        self.tx_issues().any(|issue| issue.code == code)
133    }
134}
135
136impl std::fmt::Display for UpstreamError {
137    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138        match self
139            .outcome
140            .as_ref()
141            .and_then(|outcome| outcome.issues.first())
142        {
143            Some(issue) => write!(f, "{} ({})", self.status, issue.code),
144            None => write!(f, "{}", self.status),
145        }
146    }
147}
148
149/// A response body that is not the resource the operation answers with.
150#[derive(Debug, thiserror::Error)]
151#[non_exhaustive]
152pub enum BodyError {
153    /// The body is not the FHIR JSON resource the route declares.
154    #[error(transparent)]
155    Json(#[from] serde_json::Error),
156    /// The `Parameters` resource does not fit the operation's declared
157    /// parameter set.
158    #[error(transparent)]
159    Parameters(#[from] fhir_types::operation::ParametersError),
160    /// A decoded value has no FHIR JSON form, so it could not be read back.
161    #[error(transparent)]
162    Encode(#[from] fhir_types::codec::EncodeError),
163    /// A parameter the operation declares as mandatory states no value.
164    #[error("the answer states no value for the mandatory parameter `{parameter}`")]
165    MissingValue {
166        /// The parameter, dotted for a part.
167        parameter: &'static str,
168    },
169    /// The answer to a `batch` is not a `batch-response` `Bundle`.
170    #[error("a batch answered a `{found}` Bundle, not a `batch-response`")]
171    BatchType {
172        /// The `Bundle.type` the answer declared.
173        found: String,
174    },
175    /// A `Bundle` entry carries no `response.status`, or one that is not an
176    /// HTTP status line.
177    #[error("a batch response entry carries no usable response.status")]
178    EntryStatus,
179    /// A `Bundle` entry carries no resource, so the answer to its request is
180    /// absent rather than negative.
181    #[error("a batch response entry carries no resource")]
182    EntryResource,
183    /// A parameter bound `required` to a value set carries a code outside it.
184    #[error("the answer carries `{code}` for `{parameter}`, a code outside its required value set")]
185    UnknownCode {
186        /// The parameter, dotted for a part.
187        parameter: &'static str,
188        /// The code the server sent.
189        code: String,
190    },
191    /// The answer to a search is not a `searchset` `Bundle`.
192    #[error("a search answered a `{found}` Bundle, not a `searchset`")]
193    SearchType {
194        /// The `Bundle.type` the answer declared.
195        found: String,
196    },
197}
198
199/// A call into the terminology server that did not reach an answer.
200#[derive(Debug, thiserror::Error)]
201#[non_exhaustive]
202pub enum Error {
203    /// The request could not be built into a URL under the configured base.
204    #[error("{base} cannot be a base URL for a FHIR service")]
205    BaseUrl {
206        /// The configured base URL.
207        base: Url,
208    },
209    /// The HTTP client could not be built from the configuration.
210    #[error("the HTTP client could not be built")]
211    ClientBuild {
212        /// What the HTTP stack reported.
213        #[source]
214        source: reqwest::Error,
215    },
216    /// A request body could not be written as FHIR JSON.
217    #[error("the {operation} request could not be written as FHIR JSON")]
218    RequestBody {
219        /// The operation, as `Resource/$code`.
220        operation: &'static str,
221        /// What the encoder reported.
222        #[source]
223        source: serde_json::Error,
224    },
225    /// The request carries a parameter the configured release's
226    /// `OperationDefinition` does not declare and has no equivalent for, so
227    /// nothing was sent.
228    ///
229    /// STU3 `ConceptMap/$translate` has no `url`, and STU3
230    /// `ValueSet/$validate-code` has no `valueSetVersion`
231    /// (<https://hl7.org/fhir/STU3/terminology-service.html>). Sending the
232    /// request without the parameter would ask the server a different
233    /// question than the caller asked.
234    #[error(
235        "the {operation} request carries `{parameter}`, which FHIR {} declares no parameter for; nothing was sent",
236        .wire_version.as_str()
237    )]
238    UnsupportedParameter {
239        /// The operation, as `Resource/$code`.
240        operation: &'static str,
241        /// The parameter, as the R4 `OperationDefinition` names it.
242        parameter: &'static str,
243        /// The release the client is configured for.
244        wire_version: WireVersion,
245    },
246    /// The request carries a code with no system, which the configured
247    /// release requires, so nothing was sent.
248    ///
249    /// STU3 `ValueSet/$validate-code` reads "If a code is provided, a system
250    /// must be provided" (`OperationDefinition-ValueSet-validate-code`,
251    /// 3.0.2), so a [`Coded::InContext`](crate::outcome::Coded::InContext)
252    /// code cannot be asked of an STU3 server; R4 and R4B accept a context
253    /// in place of the system.
254    #[error(
255        "the {operation} request carries a code with no system, which FHIR {} requires; nothing was sent",
256        .wire_version.as_str()
257    )]
258    SystemRequired {
259        /// The operation, as `Resource/$code`.
260        operation: &'static str,
261        /// The release the client is configured for.
262        wire_version: WireVersion,
263    },
264    /// A numeric input does not fit the FHIR `integer` the operation declares
265    /// (<https://hl7.org/fhir/R4/datatypes.html#integer>), so nothing was
266    /// sent.
267    #[error(
268        "the {operation} request carries a `{parameter}` larger than a FHIR integer holds; nothing was sent"
269    )]
270    ParameterRange {
271        /// The operation, as `Resource/$code`.
272        operation: &'static str,
273        /// The parameter, as the R4 `OperationDefinition` names it.
274        parameter: &'static str,
275        /// What the conversion reported.
276        #[source]
277        source: std::num::TryFromIntError,
278    },
279    /// The request never reached the server.
280    #[error("the request to {url} could not be sent")]
281    Transport {
282        /// The URL that was called.
283        url: Url,
284        /// What the HTTP stack reported.
285        #[source]
286        source: reqwest::Error,
287    },
288    /// The server did not answer inside the configured timeout.
289    #[error("the request to {url} timed out")]
290    Timeout {
291        /// The URL that was called.
292        url: Url,
293        /// What the HTTP stack reported.
294        #[source]
295        source: reqwest::Error,
296    },
297    /// The server answered `5xx`.
298    #[error("the terminology server answered {upstream} for {url}")]
299    ServerFailure {
300        /// The URL that was called.
301        url: Url,
302        /// The upstream status and body.
303        upstream: Box<UpstreamError>,
304    },
305    /// The server refused the credentials.
306    #[error("the terminology server refused the credentials for {url}")]
307    Unauthorized {
308        /// The URL that was called.
309        url: Url,
310        /// The `WWW-Authenticate` challenge, when the server sent one.
311        challenge: Option<String>,
312        /// The upstream status and body.
313        upstream: Box<UpstreamError>,
314    },
315    /// The token provider obtained no token, so nothing was sent.
316    #[error("no token could be obtained for the request to {url}")]
317    Token {
318        /// The URL the request was for.
319        url: Url,
320        /// The provider's failure.
321        #[source]
322        source: TokenFailure,
323    },
324    /// The server refused the token (`401`) and no new one could be
325    /// obtained, so the request was not sent again.
326    #[error("the terminology server refused the token for {url} and no new one could be obtained")]
327    Renewal {
328        /// The URL that was called.
329        url: Url,
330        /// The upstream status and body of the refusal.
331        upstream: Box<UpstreamError>,
332        /// The provider's failure.
333        #[source]
334        source: TokenFailure,
335    },
336    /// The server refused the operation.
337    #[error("the terminology server refused {operation} with {upstream}")]
338    Refused {
339        /// The operation, as `Resource/$code`.
340        operation: &'static str,
341        /// The URL that was called.
342        url: Url,
343        /// The upstream status and body, with any `tx-issue-type` coding.
344        upstream: Box<UpstreamError>,
345    },
346    /// The response body could not be decoded.
347    #[error("the body of the {operation} answer from {url} could not be decoded")]
348    Body {
349        /// The operation, as `Resource/$code`.
350        operation: &'static str,
351        /// The URL that was called.
352        url: Url,
353        /// What the decoder reported.
354        #[source]
355        source: Box<BodyError>,
356    },
357    /// The batch answered a different number of entries than were sent.
358    #[error("the batch answered {answered} entries for {sent} requests")]
359    BatchArity {
360        /// How many requests were sent.
361        sent: usize,
362        /// How many entries came back.
363        answered: usize,
364    },
365}
366
367impl Error {
368    /// Returns the variant's name as a short kebab-case label, for a log line
369    /// that must name the failure class and never the URL or a body.
370    #[must_use]
371    pub fn kind(&self) -> &'static str {
372        match self {
373            Self::BaseUrl { .. } => "base-url",
374            Self::ClientBuild { .. } => "client-build",
375            Self::RequestBody { .. } => "request-body",
376            Self::UnsupportedParameter { .. } => "unsupported-parameter",
377            Self::SystemRequired { .. } => "system-required",
378            Self::ParameterRange { .. } => "parameter-range",
379            Self::Transport { .. } => "transport",
380            Self::Timeout { .. } => "timeout",
381            Self::ServerFailure { .. } => "server-failure",
382            Self::Unauthorized { .. } => "unauthorized",
383            Self::Token { .. } => "token",
384            Self::Renewal { .. } => "token-renewal",
385            Self::Refused { .. } => "refused",
386            Self::Body { .. } => "body",
387            Self::BatchArity { .. } => "batch-arity",
388        }
389    }
390
391    /// Returns the status the terminology server answered with, when the
392    /// failure is an answer: a `5xx`, a refused credential or a refused
393    /// operation. A call that never reached an answer has none.
394    #[must_use]
395    pub fn upstream_status(&self) -> Option<StatusCode> {
396        match self {
397            Self::ServerFailure { upstream, .. }
398            | Self::Unauthorized { upstream, .. }
399            | Self::Renewal { upstream, .. }
400            | Self::Refused { upstream, .. } => Some(upstream.status()),
401            _ => None,
402        }
403    }
404
405    /// Returns whether a retry of the same request could succeed.
406    ///
407    /// Only a failure that did not reach the server, or one the server
408    /// reported as its own (`5xx`), is transient. The five operations declare
409    /// `affectsState: false`
410    /// (`OperationDefinition-CodeSystem-lookup`,
411    /// `OperationDefinition-ConceptMap-translate`,
412    /// `OperationDefinition-ValueSet-validate-code`,
413    /// `OperationDefinition-ValueSet-expand` and
414    /// `OperationDefinition-CodeSystem-subsumes`, R4B), so a repeat is the
415    /// same read and the `POST` form is retried like the `GET` form
416    /// (<https://hl7.org/fhir/R4/operations.html>). The STU3 definitions of
417    /// the same operations declare no such flag; retrying them on the same
418    /// ground is our own design.
419    /// The `CodeSystem` search is a `GET`, a safe method (RFC 9110 ยง9.2.1).
420    #[must_use]
421    pub fn is_retryable(&self) -> bool {
422        matches!(
423            self,
424            Self::Transport { .. } | Self::Timeout { .. } | Self::ServerFailure { .. }
425        )
426    }
427}
428
429#[cfg(test)]
430mod tests {
431    use super::{Error, TX_ISSUE_TYPE, TxIssue, UpstreamError};
432    use crate::config::WireVersion;
433    use http::StatusCode;
434
435    fn outcome_body(tx_code: &str) -> String {
436        serde_json::json!({
437            "resourceType": "OperationOutcome",
438            "issue": [{
439                "severity": "error",
440                "code": "code-invalid",
441                "details": {
442                    "coding": [{"system": TX_ISSUE_TYPE, "code": tx_code, "display": "Invalid code"}],
443                    "text": "the code is not in the code system",
444                },
445                "diagnostics": "Unknown_Code_in_Version",
446            }],
447        })
448        .to_string()
449    }
450
451    #[test]
452    fn a_tx_issue_type_coding_is_surfaced() {
453        let upstream = UpstreamError::new(
454            WireVersion::R4,
455            StatusCode::BAD_REQUEST,
456            outcome_body("invalid-code"),
457        );
458        let outcome = upstream.outcome().expect("the body is an OperationOutcome");
459        assert_eq!(1, outcome.issues.len());
460        assert!(outcome.has_tx_issue("invalid-code"));
461        assert_eq!(
462            vec![&TxIssue {
463                code: "invalid-code".to_owned(),
464                display: Some("Invalid code".to_owned()),
465            }],
466            upstream.tx_issues().collect::<Vec<_>>()
467        );
468    }
469
470    #[test]
471    fn an_r4b_outcome_decodes_the_same_way() {
472        let upstream = UpstreamError::new(
473            WireVersion::R4B,
474            StatusCode::NOT_FOUND,
475            outcome_body("not-found"),
476        );
477        assert!(
478            upstream
479                .outcome()
480                .is_some_and(|outcome| outcome.has_tx_issue("not-found"))
481        );
482    }
483
484    #[test]
485    fn a_body_that_is_not_an_outcome_stays_raw() {
486        let upstream =
487            UpstreamError::new(WireVersion::R4, StatusCode::BAD_GATEWAY, "no".to_owned());
488        assert!(upstream.outcome().is_none());
489        assert_eq!("no", upstream.body());
490        assert_eq!("502 Bad Gateway", upstream.to_string());
491    }
492
493    #[test]
494    fn only_transport_timeout_and_server_errors_are_retried() {
495        let url: url::Url = "http://tx.invalid/r4".parse().expect("a valid URL");
496        let server = Error::ServerFailure {
497            url: url.clone(),
498            upstream: Box::new(UpstreamError::new(
499                WireVersion::R4,
500                StatusCode::SERVICE_UNAVAILABLE,
501                String::new(),
502            )),
503        };
504        assert!(server.is_retryable());
505        let refused = Error::Refused {
506            operation: "CodeSystem/$lookup",
507            url,
508            upstream: Box::new(UpstreamError::new(
509                WireVersion::R4,
510                StatusCode::BAD_REQUEST,
511                String::new(),
512            )),
513        };
514        assert!(!refused.is_retryable());
515        assert_eq!("refused", refused.kind());
516        assert!(
517            !Error::BatchArity {
518                sent: 2,
519                answered: 1
520            }
521            .is_retryable()
522        );
523    }
524}