Skip to main content

rustev_remote_http/
taxonomy.rs

1//! The closed error taxonomy of spec 009, 3.6: each [`RemoteCode`] maps to
2//! exactly one spec 003 failure class, carries a default charge, and is
3//! written at the front of the failure detail as `remote:<code>` within the
4//! run record's 256-byte detail.
5
6use rustev_contract::remote::{MAX_TEXT_BYTES, RemoteCode, cut_to};
7use rustev_contract::run::Charge;
8use rustev_core::seams::{AdapterFailure, AttemptReport, CancelAck, RemoteEnd};
9
10/// The one failure class a code maps to (3.6, R-2).
11pub const fn failure_class(code: RemoteCode) -> AdapterFailure {
12    match code {
13        RemoteCode::TransportUnsent
14        | RemoteCode::TransportInterrupted
15        | RemoteCode::RemoteDeadline
16        | RemoteCode::RemoteError => AdapterFailure::Transient,
17        RemoteCode::RateLimited | RemoteCode::Overloaded => AdapterFailure::Overloaded,
18        RemoteCode::Unauthorized
19        | RemoteCode::Capability
20        | RemoteCode::MalformedRequest
21        | RemoteCode::MalformedResponse
22        | RemoteCode::IdentityMismatch => AdapterFailure::Permanent,
23        RemoteCode::Cancelled => AdapterFailure::Cancelled,
24    }
25}
26
27/// True for the refusals whose default charge is `observed{0}` (3.6).
28pub const fn is_refusal(code: RemoteCode) -> bool {
29    matches!(
30        code,
31        RemoteCode::RateLimited
32            | RemoteCode::Overloaded
33            | RemoteCode::Unauthorized
34            | RemoteCode::Capability
35            | RemoteCode::MalformedRequest
36    )
37}
38
39/// The charge a code carries when the remote side reported none (3.6).
40///
41/// `transport_unsent` is `observed{0}`: nothing left the process. A refusal
42/// is `observed{0}` only when the remote side documents that refused
43/// requests are not charged (`refusals_charged == false`); otherwise, and
44/// for every other code, `unknown`. `cancelled` is decided by
45/// [`crate::cancel::cancel_outcome`], never here; it is `unknown` here.
46pub const fn default_charge(code: RemoteCode, refusals_charged: bool) -> Charge {
47    match code {
48        RemoteCode::TransportUnsent => Charge::Observed { units: 0 },
49        c if is_refusal(c) && !refusals_charged => Charge::Observed { units: 0 },
50        _ => Charge::Unknown,
51    }
52}
53
54/// The charge to report: the reported one when there is one, else the
55/// code's default. A reported `unknown` counts as not reported.
56pub fn charge_for(code: RemoteCode, reported: Option<Charge>, refusals_charged: bool) -> Charge {
57    match (code, reported) {
58        // Nothing left the process: nothing can have been charged.
59        (RemoteCode::TransportUnsent, _) => Charge::Observed { units: 0 },
60        (_, Some(c)) if c != Charge::Unknown => c,
61        _ => default_charge(code, refusals_charged),
62    }
63}
64
65/// `remote:<code>`, then `: ` and `detail` when it is not empty, cut to 256
66/// bytes on a character boundary (spec 003, 3.9.3).
67pub fn failure_detail(code: RemoteCode, detail: &str) -> String {
68    let mut s = format!("remote:{}", code.as_str());
69    if !detail.is_empty() {
70        s.push_str(": ");
71        s.push_str(detail);
72    }
73    cut_to(&s, MAX_TEXT_BYTES)
74}
75
76/// The code a failure detail written by [`failure_detail`] begins with.
77pub fn code_of_detail(detail: &str) -> Option<RemoteCode> {
78    let rest = detail.strip_prefix("remote:")?;
79    let name = rest.split(':').next().unwrap_or(rest);
80    RemoteCode::ALL.into_iter().find(|c| c.as_str() == name)
81}
82
83/// The code for an HTTP status (3.6 and 3.11); `None` for a success.
84///
85/// 401 and 403 are `unauthorized`; 429 is `rate_limited`; 503 and 529 are
86/// `overloaded`; every other 4xx (400 and 422 by name) is
87/// `malformed_request`; everything else a success does not cover, 5xx or
88/// unclassifiable, is `remote_error`.
89pub const fn classify_status(status: u16) -> Option<RemoteCode> {
90    match status {
91        200..=299 => None,
92        401 | 403 => Some(RemoteCode::Unauthorized),
93        429 => Some(RemoteCode::RateLimited),
94        503 | 529 => Some(RemoteCode::Overloaded),
95        400..=499 => Some(RemoteCode::MalformedRequest),
96        _ => Some(RemoteCode::RemoteError),
97    }
98}
99
100/// Codes only the adapter itself can observe. A remote party that sends one
101/// is not trusted with it (it could zero a reported charge); adapters refuse
102/// such an answer as `malformed_response`.
103pub const fn is_local_only(code: RemoteCode) -> bool {
104    matches!(
105        code,
106        RemoteCode::TransportUnsent | RemoteCode::TransportInterrupted
107    )
108}
109
110/// Whether remote work may outlive a failure reported of the adapter's own
111/// accord (spec 013, 3.1): when request bytes left the process, the charge
112/// is `unknown` because nothing established a final one, and the code is
113/// one where no confirmed end arrived. That covers a transport interrupted
114/// after sending and an unparseable answer, and also `remote_deadline` and
115/// `remote_error` without a charge: the remote side gave up or failed
116/// without confirming that its work and charges stopped.
117pub fn remote_end(code: RemoteCode, sent: bool, charge: Charge) -> RemoteEnd {
118    let no_complete_answer = matches!(
119        code,
120        RemoteCode::TransportInterrupted
121            | RemoteCode::MalformedResponse
122            | RemoteCode::RemoteDeadline
123            | RemoteCode::RemoteError
124    );
125    if sent && no_complete_answer && charge == Charge::Unknown {
126        RemoteEnd::PossiblyContinuing
127    } else {
128        RemoteEnd::Finished
129    }
130}
131
132/// A classified remote failure, before it becomes a report.
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct RemoteFailure {
135    pub code: RemoteCode,
136    /// Free text after the `remote:<code>` prefix; never a credential.
137    pub detail: String,
138    pub charge: Charge,
139    pub remote: RemoteEnd,
140}
141
142impl RemoteFailure {
143    /// A failure with the charge chosen by [`charge_for`] and the remote end
144    /// by [`remote_end`]. Not for `cancelled`, which
145    /// [`crate::cancel::cancelled_report`] builds.
146    pub fn new(
147        code: RemoteCode,
148        detail: impl Into<String>,
149        reported: Option<Charge>,
150        refusals_charged: bool,
151        sent: bool,
152    ) -> Self {
153        let charge = charge_for(code, reported, refusals_charged);
154        RemoteFailure {
155            code,
156            detail: detail.into(),
157            charge,
158            remote: remote_end(code, sent, charge),
159        }
160    }
161
162    /// The seam's report: the code's class and a `remote:<code>` detail.
163    pub fn report(&self) -> AttemptReport {
164        AttemptReport {
165            result: Err((
166                failure_class(self.code),
167                failure_detail(self.code, &self.detail),
168            )),
169            charge: self.charge,
170            cancel: CancelAck::NotRequested,
171            remote: self.remote,
172        }
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn every_code_has_one_class_and_the_table_default_charge() {
182        use AdapterFailure as F;
183        use RemoteCode as C;
184        let table = [
185            (
186                C::TransportUnsent,
187                F::Transient,
188                Charge::Observed { units: 0 },
189            ),
190            (C::TransportInterrupted, F::Transient, Charge::Unknown),
191            (C::RateLimited, F::Overloaded, Charge::Observed { units: 0 }),
192            (C::Overloaded, F::Overloaded, Charge::Observed { units: 0 }),
193            (C::Unauthorized, F::Permanent, Charge::Observed { units: 0 }),
194            (C::Capability, F::Permanent, Charge::Observed { units: 0 }),
195            (
196                C::MalformedRequest,
197                F::Permanent,
198                Charge::Observed { units: 0 },
199            ),
200            (C::MalformedResponse, F::Permanent, Charge::Unknown),
201            (C::IdentityMismatch, F::Permanent, Charge::Unknown),
202            (C::RemoteDeadline, F::Transient, Charge::Unknown),
203            (C::RemoteError, F::Transient, Charge::Unknown),
204            (C::Cancelled, F::Cancelled, Charge::Unknown),
205        ];
206        assert_eq!(table.len(), C::ALL.len());
207        for (code, class, charge) in table {
208            assert_eq!(failure_class(code), class, "{code:?}");
209            assert_eq!(default_charge(code, false), charge, "{code:?}");
210            // Where refusals are charged, only an unsent request is zero.
211            let charged = default_charge(code, true);
212            if code == C::TransportUnsent {
213                assert_eq!(charged, Charge::Observed { units: 0 });
214            } else {
215                assert_eq!(charged, Charge::Unknown, "{code:?}");
216            }
217        }
218    }
219
220    #[test]
221    fn a_reported_charge_wins_except_for_an_unsent_request() {
222        let seven = Charge::Observed { units: 7 };
223        assert_eq!(
224            charge_for(RemoteCode::RemoteError, Some(seven), true),
225            seven
226        );
227        assert_eq!(
228            charge_for(RemoteCode::RateLimited, Some(Charge::Unknown), false),
229            Charge::Observed { units: 0 }
230        );
231        assert_eq!(
232            charge_for(RemoteCode::TransportUnsent, Some(seven), true),
233            Charge::Observed { units: 0 }
234        );
235    }
236
237    #[test]
238    fn statuses_map_to_codes() {
239        let cases = [
240            (200, None),
241            (204, None),
242            (400, Some(RemoteCode::MalformedRequest)),
243            (401, Some(RemoteCode::Unauthorized)),
244            (403, Some(RemoteCode::Unauthorized)),
245            (404, Some(RemoteCode::MalformedRequest)),
246            (413, Some(RemoteCode::MalformedRequest)),
247            (422, Some(RemoteCode::MalformedRequest)),
248            (429, Some(RemoteCode::RateLimited)),
249            (500, Some(RemoteCode::RemoteError)),
250            (502, Some(RemoteCode::RemoteError)),
251            (503, Some(RemoteCode::Overloaded)),
252            (529, Some(RemoteCode::Overloaded)),
253            (302, Some(RemoteCode::RemoteError)),
254            (100, Some(RemoteCode::RemoteError)),
255            (999, Some(RemoteCode::RemoteError)),
256        ];
257        for (s, c) in cases {
258            assert_eq!(classify_status(s), c, "{s}");
259        }
260    }
261
262    #[test]
263    fn details_carry_the_code_within_256_bytes() {
264        for c in RemoteCode::ALL {
265            let d = failure_detail(c, &"x".repeat(1000));
266            assert!(d.len() <= 256);
267            assert!(d.starts_with(&format!("remote:{}", c.as_str())));
268            assert_eq!(code_of_detail(&d), Some(c));
269        }
270        assert_eq!(
271            failure_detail(RemoteCode::Capability, ""),
272            "remote:capability"
273        );
274        assert_eq!(
275            code_of_detail("remote:capability"),
276            Some(RemoteCode::Capability)
277        );
278        assert_eq!(code_of_detail("no rule matched"), None);
279    }
280
281    #[test]
282    fn possibly_continuing_only_when_sent_and_unanswered() {
283        let u = Charge::Unknown;
284        let pc = RemoteEnd::PossiblyContinuing;
285        assert_eq!(remote_end(RemoteCode::TransportInterrupted, true, u), pc);
286        assert_eq!(remote_end(RemoteCode::MalformedResponse, true, u), pc);
287        assert_eq!(
288            remote_end(
289                RemoteCode::MalformedResponse,
290                true,
291                Charge::Observed { units: 1 }
292            ),
293            RemoteEnd::Finished
294        );
295        assert_eq!(
296            remote_end(RemoteCode::TransportInterrupted, false, u),
297            RemoteEnd::Finished
298        );
299        // A remote failure without a final charge does not establish a stop.
300        assert_eq!(remote_end(RemoteCode::RemoteError, true, u), pc);
301        assert_eq!(remote_end(RemoteCode::RemoteDeadline, true, u), pc);
302        assert_eq!(
303            remote_end(
304                RemoteCode::RemoteDeadline,
305                true,
306                Charge::Observed { units: 1 }
307            ),
308            RemoteEnd::Finished
309        );
310        assert_eq!(
311            remote_end(RemoteCode::Overloaded, true, u),
312            RemoteEnd::Finished
313        );
314        assert!(is_local_only(RemoteCode::TransportUnsent));
315        assert!(is_local_only(RemoteCode::TransportInterrupted));
316        assert!(!is_local_only(RemoteCode::RemoteError));
317    }
318}