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 /// A numeric input does not fit the FHIR `integer` the operation declares
247 /// (<https://hl7.org/fhir/R4/datatypes.html#integer>), so nothing was
248 /// sent.
249 #[error(
250 "the {operation} request carries a `{parameter}` larger than a FHIR integer holds; nothing was sent"
251 )]
252 ParameterRange {
253 /// The operation, as `Resource/$code`.
254 operation: &'static str,
255 /// The parameter, as the R4 `OperationDefinition` names it.
256 parameter: &'static str,
257 /// What the conversion reported.
258 #[source]
259 source: std::num::TryFromIntError,
260 },
261 /// The request never reached the server.
262 #[error("the request to {url} could not be sent")]
263 Transport {
264 /// The URL that was called.
265 url: Url,
266 /// What the HTTP stack reported.
267 #[source]
268 source: reqwest::Error,
269 },
270 /// The server did not answer inside the configured timeout.
271 #[error("the request to {url} timed out")]
272 Timeout {
273 /// The URL that was called.
274 url: Url,
275 /// What the HTTP stack reported.
276 #[source]
277 source: reqwest::Error,
278 },
279 /// The server answered `5xx`.
280 #[error("the terminology server answered {upstream} for {url}")]
281 ServerFailure {
282 /// The URL that was called.
283 url: Url,
284 /// The upstream status and body.
285 upstream: Box<UpstreamError>,
286 },
287 /// The server refused the credentials.
288 #[error("the terminology server refused the credentials for {url}")]
289 Unauthorized {
290 /// The URL that was called.
291 url: Url,
292 /// The `WWW-Authenticate` challenge, when the server sent one.
293 challenge: Option<String>,
294 /// The upstream status and body.
295 upstream: Box<UpstreamError>,
296 },
297 /// The token provider obtained no token, so nothing was sent.
298 #[error("no token could be obtained for the request to {url}")]
299 Token {
300 /// The URL the request was for.
301 url: Url,
302 /// The provider's failure.
303 #[source]
304 source: TokenFailure,
305 },
306 /// The server refused the token (`401`) and no new one could be
307 /// obtained, so the request was not sent again.
308 #[error("the terminology server refused the token for {url} and no new one could be obtained")]
309 Renewal {
310 /// The URL that was called.
311 url: Url,
312 /// The upstream status and body of the refusal.
313 upstream: Box<UpstreamError>,
314 /// The provider's failure.
315 #[source]
316 source: TokenFailure,
317 },
318 /// The server refused the operation.
319 #[error("the terminology server refused {operation} with {upstream}")]
320 Refused {
321 /// The operation, as `Resource/$code`.
322 operation: &'static str,
323 /// The URL that was called.
324 url: Url,
325 /// The upstream status and body, with any `tx-issue-type` coding.
326 upstream: Box<UpstreamError>,
327 },
328 /// The response body could not be decoded.
329 #[error("the body of the {operation} answer from {url} could not be decoded")]
330 Body {
331 /// The operation, as `Resource/$code`.
332 operation: &'static str,
333 /// The URL that was called.
334 url: Url,
335 /// What the decoder reported.
336 #[source]
337 source: Box<BodyError>,
338 },
339 /// The batch answered a different number of entries than were sent.
340 #[error("the batch answered {answered} entries for {sent} requests")]
341 BatchArity {
342 /// How many requests were sent.
343 sent: usize,
344 /// How many entries came back.
345 answered: usize,
346 },
347}
348
349impl Error {
350 /// Returns the variant's name as a short kebab-case label, for a log line
351 /// that must name the failure class and never the URL or a body.
352 #[must_use]
353 pub fn kind(&self) -> &'static str {
354 match self {
355 Self::BaseUrl { .. } => "base-url",
356 Self::ClientBuild { .. } => "client-build",
357 Self::RequestBody { .. } => "request-body",
358 Self::UnsupportedParameter { .. } => "unsupported-parameter",
359 Self::ParameterRange { .. } => "parameter-range",
360 Self::Transport { .. } => "transport",
361 Self::Timeout { .. } => "timeout",
362 Self::ServerFailure { .. } => "server-failure",
363 Self::Unauthorized { .. } => "unauthorized",
364 Self::Token { .. } => "token",
365 Self::Renewal { .. } => "token-renewal",
366 Self::Refused { .. } => "refused",
367 Self::Body { .. } => "body",
368 Self::BatchArity { .. } => "batch-arity",
369 }
370 }
371
372 /// Returns the status the terminology server answered with, when the
373 /// failure is an answer: a `5xx`, a refused credential or a refused
374 /// operation. A call that never reached an answer has none.
375 #[must_use]
376 pub fn upstream_status(&self) -> Option<StatusCode> {
377 match self {
378 Self::ServerFailure { upstream, .. }
379 | Self::Unauthorized { upstream, .. }
380 | Self::Renewal { upstream, .. }
381 | Self::Refused { upstream, .. } => Some(upstream.status()),
382 _ => None,
383 }
384 }
385
386 /// Returns whether a retry of the same request could succeed.
387 ///
388 /// Only a failure that did not reach the server, or one the server
389 /// reported as its own (`5xx`), is transient. The five operations declare
390 /// `affectsState: false`
391 /// (`OperationDefinition-CodeSystem-lookup`,
392 /// `OperationDefinition-ConceptMap-translate`,
393 /// `OperationDefinition-ValueSet-validate-code`,
394 /// `OperationDefinition-ValueSet-expand` and
395 /// `OperationDefinition-CodeSystem-subsumes`, R4B), so a repeat is the
396 /// same read and the `POST` form is retried like the `GET` form
397 /// (<https://hl7.org/fhir/R4/operations.html>). The STU3 definitions of
398 /// the same operations declare no such flag; retrying them on the same
399 /// ground is our own design.
400 /// The `CodeSystem` search is a `GET`, a safe method (RFC 9110 ยง9.2.1).
401 #[must_use]
402 pub fn is_retryable(&self) -> bool {
403 matches!(
404 self,
405 Self::Transport { .. } | Self::Timeout { .. } | Self::ServerFailure { .. }
406 )
407 }
408}
409
410#[cfg(test)]
411mod tests {
412 use super::{Error, TX_ISSUE_TYPE, TxIssue, UpstreamError};
413 use crate::config::WireVersion;
414 use http::StatusCode;
415
416 fn outcome_body(tx_code: &str) -> String {
417 serde_json::json!({
418 "resourceType": "OperationOutcome",
419 "issue": [{
420 "severity": "error",
421 "code": "code-invalid",
422 "details": {
423 "coding": [{"system": TX_ISSUE_TYPE, "code": tx_code, "display": "Invalid code"}],
424 "text": "the code is not in the code system",
425 },
426 "diagnostics": "Unknown_Code_in_Version",
427 }],
428 })
429 .to_string()
430 }
431
432 #[test]
433 fn a_tx_issue_type_coding_is_surfaced() {
434 let upstream = UpstreamError::new(
435 WireVersion::R4,
436 StatusCode::BAD_REQUEST,
437 outcome_body("invalid-code"),
438 );
439 let outcome = upstream.outcome().expect("the body is an OperationOutcome");
440 assert_eq!(1, outcome.issues.len());
441 assert!(outcome.has_tx_issue("invalid-code"));
442 assert_eq!(
443 vec![&TxIssue {
444 code: "invalid-code".to_owned(),
445 display: Some("Invalid code".to_owned()),
446 }],
447 upstream.tx_issues().collect::<Vec<_>>()
448 );
449 }
450
451 #[test]
452 fn an_r4b_outcome_decodes_the_same_way() {
453 let upstream = UpstreamError::new(
454 WireVersion::R4B,
455 StatusCode::NOT_FOUND,
456 outcome_body("not-found"),
457 );
458 assert!(
459 upstream
460 .outcome()
461 .is_some_and(|outcome| outcome.has_tx_issue("not-found"))
462 );
463 }
464
465 #[test]
466 fn a_body_that_is_not_an_outcome_stays_raw() {
467 let upstream =
468 UpstreamError::new(WireVersion::R4, StatusCode::BAD_GATEWAY, "no".to_owned());
469 assert!(upstream.outcome().is_none());
470 assert_eq!("no", upstream.body());
471 assert_eq!("502 Bad Gateway", upstream.to_string());
472 }
473
474 #[test]
475 fn only_transport_timeout_and_server_errors_are_retried() {
476 let url: url::Url = "http://tx.invalid/r4".parse().expect("a valid URL");
477 let server = Error::ServerFailure {
478 url: url.clone(),
479 upstream: Box::new(UpstreamError::new(
480 WireVersion::R4,
481 StatusCode::SERVICE_UNAVAILABLE,
482 String::new(),
483 )),
484 };
485 assert!(server.is_retryable());
486 let refused = Error::Refused {
487 operation: "CodeSystem/$lookup",
488 url,
489 upstream: Box::new(UpstreamError::new(
490 WireVersion::R4,
491 StatusCode::BAD_REQUEST,
492 String::new(),
493 )),
494 };
495 assert!(!refused.is_retryable());
496 assert_eq!("refused", refused.kind());
497 assert!(
498 !Error::BatchArity {
499 sent: 2,
500 answered: 1
501 }
502 .is_retryable()
503 );
504 }
505}