Skip to main content

headless_lms_credit_registration/runtime/suotar/
codes.rs

1//! What a Suotar per-item `code` means: the one place the wire vocabulary is spelled out. What may
2//! be done about the ledger error code it maps to is models' `classification::retryability`.
3
4use headless_lms_models::credit_registrations::{
5    CreditRegistrationErrorCode, CreditRegistrationState,
6};
7use headless_lms_models::library::credit_registration::classification::{
8    Retryability, is_waiting_error, retryability,
9};
10use headless_lms_utils::services::suotar::{SuotarEndpoint, SuotarItemStatus};
11
12/// What one `code` says about the row that carries it. Every view below narrows this.
13#[derive(Debug, PartialEq, Eq, Clone, Copy)]
14pub(super) enum WireOutcome {
15    /// The answer settles the row, in this state.
16    Settled(CreditRegistrationState),
17    /// A real answer that decides nothing by itself: the lookup endpoints' success codes, and
18    /// verify's `submissionPending`, which only means Sisu has not finished yet.
19    Unsettled,
20    Failure(CreditRegistrationErrorCode),
21}
22
23/// Suotar's answer for a later item of a batch that repeats an earlier one; it names the earlier
24/// item's submission.
25pub(super) const DUPLICATE_REQUEST_ITEM_CODE: &str = "duplicateRequestItem";
26/// Suotar's answer for a student number that names nobody.
27pub(super) const PERSON_NOT_FOUND_CODE: &str = "personNotFound";
28
29/// The contract's own reading of a `code`, before any hardening of ours.
30///
31/// An unrecognised code is a failure rather than an error, since Suotar may add codes; which of
32/// them are even recoverable is [`retryability`].
33fn wire_outcome(code: &str) -> WireOutcome {
34    use CreditRegistrationErrorCode as Code;
35    use CreditRegistrationState as State;
36    match code {
37        "sent" => WireOutcome::Settled(State::AwaitingVerification),
38        // An error on the wire, but it names the submission an earlier item of the batch made for
39        // the same completion, which is ours to verify.
40        DUPLICATE_REQUEST_ITEM_CODE => WireOutcome::Settled(State::AwaitingVerification),
41        "registered" => WireOutcome::Settled(State::Registered),
42        "duplicateAttainment" => WireOutcome::Settled(State::Duplicate),
43        "notImprovedAttainment" => WireOutcome::Settled(State::NotImproved),
44        "personFound" | "enrolmentFound" | "enrolmentsListed" | "courseAllowed"
45        | "submissionPending" => WireOutcome::Unsettled,
46        "notRegistered" => WireOutcome::Failure(Code::NotRegistered),
47        PERSON_NOT_FOUND_CODE => WireOutcome::Failure(Code::PersonNotFound),
48        "courseCodeNotFound" => WireOutcome::Failure(Code::CourseCodeNotFound),
49        "enrolmentNotFound" => WireOutcome::Failure(Code::EnrolmentNotFound),
50        "enrolmentNotAccepted" => WireOutcome::Failure(Code::EnrolmentNotAccepted),
51        "invalidGradeForGradeScale" => WireOutcome::Failure(Code::InvalidGradeForGradeScale),
52        "gradeScaleMismatch" => WireOutcome::Failure(Code::GradeScaleMismatch),
53        "courseNotAllowed" => WireOutcome::Failure(Code::CourseNotAllowed),
54        "invalidCredits" => WireOutcome::Failure(Code::InvalidCredits),
55        "studyRightNotValid" => WireOutcome::Failure(Code::StudyRightNotValid),
56        "sisuValidationFailed" => WireOutcome::Failure(Code::SisuValidationFailed),
57        "sisuTimeout" => WireOutcome::Failure(Code::SisuTimeout),
58        "misregistered" => WireOutcome::Failure(Code::Misregistered),
59        "unauthorized" => WireOutcome::Failure(Code::Unauthorized),
60        "malformedRequest" => WireOutcome::Failure(Code::MalformedRequest),
61        "serviceTemporarilyUnavailable" => {
62            WireOutcome::Failure(Code::ServiceTemporarilyUnavailable)
63        }
64        _ => WireOutcome::Failure(Code::Unknown),
65    }
66}
67
68/// [`wire_outcome`] hardened for the endpoint the code arrived on. Every caller that has an
69/// endpoint to name goes through here.
70pub(super) fn outcome_of(endpoint: SuotarEndpoint, code: &str) -> WireOutcome {
71    let outcome = wire_outcome(code);
72    if endpoint != SuotarEndpoint::ImportAttainments {
73        return outcome;
74    }
75    match outcome {
76        // Suotar's import has no per-item transient, so one arriving there is no evidence that
77        // nothing was created; retrying it could put a second attainment on a transcript.
78        WireOutcome::Failure(code) if retryability(code) == Retryability::RetryableTransient => {
79            WireOutcome::Failure(CreditRegistrationErrorCode::SisuTimeout)
80        }
81        // `registered` is not an import answer, so what the item created is unknown.
82        WireOutcome::Settled(CreditRegistrationState::Registered) => {
83            WireOutcome::Failure(CreditRegistrationErrorCode::Unknown)
84        }
85        outcome => outcome,
86    }
87}
88
89/// Whether an item says the registry could not be reached right now. Suotar sends
90/// `serviceTemporarilyUnavailable` only for a whole request, so an item carrying it means its API
91/// changed, and is still worth backing off from. On import, `sisuTimeout` is what every item answers
92/// while Sisu is down.
93fn is_service_unavailable_code(endpoint: SuotarEndpoint, code: &str) -> bool {
94    match wire_outcome(code) {
95        WireOutcome::Failure(CreditRegistrationErrorCode::ServiceTemporarilyUnavailable) => true,
96        WireOutcome::Failure(CreditRegistrationErrorCode::SisuTimeout) => {
97            endpoint == SuotarEndpoint::ImportAttainments
98        }
99        _ => false,
100    }
101}
102
103/// Whether an import item says Sisu timed out: an answer from Suotar, not a failure of it.
104fn is_sisu_timeout_code(endpoint: SuotarEndpoint, code: &str) -> bool {
105    endpoint == SuotarEndpoint::ImportAttainments
106        && wire_outcome(code) == WireOutcome::Failure(CreditRegistrationErrorCode::SisuTimeout)
107}
108
109/// Whether every item of an answer says the registry could not be reached. One good item makes it a
110/// plain answer, because something moved.
111pub(super) fn is_all_unavailable<'a>(
112    endpoint: SuotarEndpoint,
113    items: impl IntoIterator<Item = (SuotarItemStatus, &'a str)>,
114) -> bool {
115    let mut items = items.into_iter().peekable();
116    items.peek().is_some()
117        && items.all(|(status, code)| {
118            status == SuotarItemStatus::Error && is_service_unavailable_code(endpoint, code)
119        })
120}
121
122/// Whether every item code of an answer says Sisu timed out: Suotar itself answered.
123pub(super) fn is_only_sisu_timeouts<'a>(
124    endpoint: SuotarEndpoint,
125    codes: impl IntoIterator<Item = &'a str>,
126) -> bool {
127    codes
128        .into_iter()
129        .all(|code| is_sisu_timeout_code(endpoint, code))
130}
131
132/// Whether an error item's `code` only says "not yet, check again later", so the call log counts it
133/// as pending rather than as an error: `submissionPending`, verify's `notRegistered`, and resolve's
134/// `enrolmentNotFound` and `enrolmentNotAccepted`.
135pub fn is_waiting_item(endpoint: SuotarEndpoint, code: &str) -> bool {
136    match outcome_of(endpoint, code) {
137        WireOutcome::Unsettled => true,
138        WireOutcome::Failure(code) => is_waiting_error(code),
139        WireOutcome::Settled(_) => false,
140    }
141}
142
143/// Suotar's per-item `code` as a ledger error code, hardened for the endpoint it arrived on.
144/// `None` where the code names no failure to record.
145pub(super) fn map_code(
146    endpoint: SuotarEndpoint,
147    code: &str,
148) -> Option<CreditRegistrationErrorCode> {
149    match outcome_of(endpoint, code) {
150        WireOutcome::Failure(code) => Some(code),
151        _ => None,
152    }
153}
154
155/// The state a `code` settles a row in, or `None` where it settles nothing. On import, `sent` means
156/// Sisu has not answered yet.
157pub(super) fn settled_state(
158    endpoint: SuotarEndpoint,
159    code: &str,
160) -> Option<CreditRegistrationState> {
161    match outcome_of(endpoint, code) {
162        WireOutcome::Settled(state) => Some(state),
163        _ => None,
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use CreditRegistrationErrorCode as Code;
171
172    #[test]
173    fn only_the_unavailability_codes_read_as_the_registry_being_unreachable() {
174        assert!(is_service_unavailable_code(
175            SuotarEndpoint::VerifyAttainments,
176            "serviceTemporarilyUnavailable"
177        ));
178        assert!(is_service_unavailable_code(
179            SuotarEndpoint::ImportAttainments,
180            "sisuTimeout"
181        ));
182        assert!(!is_service_unavailable_code(
183            SuotarEndpoint::VerifyAttainments,
184            "notRegistered"
185        ));
186        assert!(!is_service_unavailable_code(
187            SuotarEndpoint::VerifyAttainments,
188            "sisuTimeout"
189        ));
190    }
191
192    #[test]
193    fn every_documented_error_code_maps() {
194        let cases = [
195            (
196                SuotarEndpoint::ResolvePersons,
197                "personNotFound",
198                Code::PersonNotFound,
199            ),
200            (
201                SuotarEndpoint::ResolvePersons,
202                "serviceTemporarilyUnavailable",
203                Code::ServiceTemporarilyUnavailable,
204            ),
205            (
206                SuotarEndpoint::ResolveEnrolments,
207                "personNotFound",
208                Code::PersonNotFound,
209            ),
210            (
211                SuotarEndpoint::ResolveEnrolments,
212                "courseCodeNotFound",
213                Code::CourseCodeNotFound,
214            ),
215            (
216                SuotarEndpoint::ResolveEnrolments,
217                "enrolmentNotFound",
218                Code::EnrolmentNotFound,
219            ),
220            (
221                SuotarEndpoint::ResolveEnrolments,
222                "enrolmentNotAccepted",
223                Code::EnrolmentNotAccepted,
224            ),
225            (
226                SuotarEndpoint::ImportAttainments,
227                "invalidGradeForGradeScale",
228                Code::InvalidGradeForGradeScale,
229            ),
230            (
231                SuotarEndpoint::ImportAttainments,
232                "courseNotAllowed",
233                Code::CourseNotAllowed,
234            ),
235            (
236                SuotarEndpoint::ImportAttainments,
237                "invalidCredits",
238                Code::InvalidCredits,
239            ),
240            (
241                SuotarEndpoint::ImportAttainments,
242                "studyRightNotValid",
243                Code::StudyRightNotValid,
244            ),
245            (
246                SuotarEndpoint::ImportAttainments,
247                "gradeScaleMismatch",
248                Code::GradeScaleMismatch,
249            ),
250            (
251                SuotarEndpoint::ImportAttainments,
252                "registered",
253                Code::Unknown,
254            ),
255            (
256                SuotarEndpoint::VerifyAttainments,
257                "notRegistered",
258                Code::NotRegistered,
259            ),
260            (
261                SuotarEndpoint::ImportAttainments,
262                "sisuValidationFailed",
263                Code::SisuValidationFailed,
264            ),
265            (
266                SuotarEndpoint::ImportAttainments,
267                "sisuTimeout",
268                Code::SisuTimeout,
269            ),
270            (
271                SuotarEndpoint::VerifyAttainments,
272                "misregistered",
273                Code::Misregistered,
274            ),
275            (
276                SuotarEndpoint::VerifyAttainments,
277                "serviceTemporarilyUnavailable",
278                Code::ServiceTemporarilyUnavailable,
279            ),
280            (
281                SuotarEndpoint::ListByCourse,
282                "courseCodeNotFound",
283                Code::CourseCodeNotFound,
284            ),
285            (
286                SuotarEndpoint::ResolvePersons,
287                "unauthorized",
288                Code::Unauthorized,
289            ),
290            (
291                SuotarEndpoint::ResolvePersons,
292                "malformedRequest",
293                Code::MalformedRequest,
294            ),
295        ];
296        for (endpoint, code, expected) in cases {
297            assert_eq!(
298                map_code(endpoint, code),
299                Some(expected),
300                "{code} on {endpoint:?}"
301            );
302        }
303    }
304
305    #[test]
306    fn no_code_that_needs_no_recording_becomes_an_error() {
307        for (endpoint, code) in [
308            (SuotarEndpoint::ResolvePersons, "personFound"),
309            (SuotarEndpoint::ResolveEnrolments, "enrolmentFound"),
310            (SuotarEndpoint::ImportAttainments, "sent"),
311            (SuotarEndpoint::ImportAttainments, "duplicateRequestItem"),
312            (SuotarEndpoint::ImportAttainments, "duplicateAttainment"),
313            (SuotarEndpoint::ImportAttainments, "notImprovedAttainment"),
314            (SuotarEndpoint::VerifyAttainments, "registered"),
315            (SuotarEndpoint::VerifyAttainments, "submissionPending"),
316            (SuotarEndpoint::ListByCourse, "enrolmentsListed"),
317            (SuotarEndpoint::ValidateCourseCodes, "courseAllowed"),
318        ] {
319            assert_eq!(map_code(endpoint, code), None, "{code} on {endpoint:?}");
320        }
321    }
322
323    #[test]
324    fn an_item_level_transient_on_import_is_uncertain_rather_than_retryable() {
325        for code in [
326            "serviceTemporarilyUnavailable",
327            "notRegistered",
328            "unauthorized",
329            "malformedRequest",
330        ] {
331            assert_eq!(
332                map_code(SuotarEndpoint::ImportAttainments, code),
333                Some(Code::SisuTimeout),
334                "{code}"
335            );
336        }
337    }
338
339    /// The success half of the vocabulary, which decides where a row ends up rather than what went
340    /// wrong with it.
341    #[test]
342    fn every_code_that_settles_a_row_names_the_state_it_settles_it_in() {
343        use CreditRegistrationState as State;
344        for (code, expected) in [
345            ("sent", State::AwaitingVerification),
346            ("duplicateRequestItem", State::AwaitingVerification),
347            ("duplicateAttainment", State::Duplicate),
348            ("notImprovedAttainment", State::NotImproved),
349        ] {
350            assert_eq!(
351                settled_state(SuotarEndpoint::ImportAttainments, code),
352                Some(expected),
353                "{code}"
354            );
355        }
356        assert_eq!(
357            settled_state(SuotarEndpoint::VerifyAttainments, "registered"),
358            Some(State::Registered)
359        );
360        assert_eq!(
361            settled_state(SuotarEndpoint::ImportAttainments, "registered"),
362            None
363        );
364        for code in [
365            "notRegistered",
366            "submissionPending",
367            "personFound",
368            "sisuTimeout",
369        ] {
370            assert_eq!(
371                settled_state(SuotarEndpoint::VerifyAttainments, code),
372                None,
373                "{code}"
374            );
375        }
376    }
377
378    #[test]
379    fn only_not_yet_answers_count_as_waiting() {
380        for (endpoint, code) in [
381            (SuotarEndpoint::VerifyAttainments, "submissionPending"),
382            (SuotarEndpoint::VerifyAttainments, "notRegistered"),
383            (SuotarEndpoint::ResolveEnrolments, "enrolmentNotFound"),
384            (SuotarEndpoint::ResolveEnrolments, "enrolmentNotAccepted"),
385        ] {
386            assert!(is_waiting_item(endpoint, code), "{code} on {endpoint:?}");
387        }
388        for (endpoint, code) in [
389            (SuotarEndpoint::ImportAttainments, "notRegistered"),
390            (SuotarEndpoint::VerifyAttainments, "misregistered"),
391            (SuotarEndpoint::ResolvePersons, "personNotFound"),
392            (SuotarEndpoint::ImportAttainments, "duplicateRequestItem"),
393            (
394                SuotarEndpoint::VerifyAttainments,
395                "somethingSuotarAddedLater",
396            ),
397        ] {
398            assert!(!is_waiting_item(endpoint, code), "{code} on {endpoint:?}");
399        }
400    }
401
402    #[test]
403    fn a_code_suotar_adds_later_maps_to_unknown_rather_than_failing() {
404        assert_eq!(
405            map_code(
406                SuotarEndpoint::VerifyAttainments,
407                "somethingSuotarAddedLater"
408            ),
409            Some(Code::Unknown)
410        );
411    }
412
413    #[test]
414    fn an_answer_is_all_unavailable_only_when_every_item_errs_unavailable() {
415        use SuotarItemStatus::{Error, Ok};
416        let endpoint = SuotarEndpoint::VerifyAttainments;
417        assert!(is_all_unavailable(
418            endpoint,
419            [(Error, "serviceTemporarilyUnavailable")]
420        ));
421        assert!(!is_all_unavailable(
422            endpoint,
423            [(Error, "serviceTemporarilyUnavailable"), (Ok, "registered")]
424        ));
425        assert!(!is_all_unavailable(endpoint, []));
426    }
427}