Skip to main content

headless_lms_models/library/credit_registration/
classification.rs

1//! What a Suotar per-item `code` means: the one place the wire vocabulary is spelled out, and the
2//! one place retryability is decided. The mock Suotar's fault validator reads it too. Timing (how
3//! long to wait) is [`super::backoff`].
4
5use utoipa::ToSchema;
6
7use crate::credit_registrations::{CreditRegistrationErrorCode, CreditRegistrationState};
8use crate::prelude::*;
9use crate::suotar_api_calls::SuotarEndpoint;
10
11/// What may be done about an error code. The class is the contract's, not the endpoint's: the
12/// import phase is the only place that upgrades a code to [`Retryability::VerifyOnly`].
13#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Hash, ToSchema)]
14#[serde(rename_all = "snake_case")]
15pub enum Retryability {
16    RetryableTransient,
17    /// The outcome is unknown and re-sending could duplicate it. Only `verify` may touch it.
18    VerifyOnly,
19    PermanentNeedsStudent,
20    PermanentNeedsAdmin,
21    PermanentNeedsConfig,
22}
23
24pub fn retryability(code: CreditRegistrationErrorCode) -> Retryability {
25    use CreditRegistrationErrorCode as Code;
26    use Retryability as Class;
27    match code {
28        Code::SisuTemporarilyUnavailable | Code::TransportError => Class::RetryableTransient,
29        // Our credentials or our request shape: re-queue the batch rather than blame its rows.
30        Code::Unauthorized | Code::MalformedRequest => Class::RetryableTransient,
31        // An answer we could not read says nothing about the row.
32        Code::UnexpectedResponse => Class::RetryableTransient,
33        Code::SisuTimeout => Class::VerifyOnly,
34        Code::PersonNotFound
35        | Code::EnrolmentNotFound
36        | Code::EnrolmentNotAccepted
37        | Code::StudyRightNotValid => Class::PermanentNeedsStudent,
38        Code::CourseCodeNotFound
39        | Code::CourseNotAllowed
40        | Code::InvalidGradeForGradeScale
41        | Code::InvalidCredits
42        | Code::NoGradeScaleMapping
43        | Code::MissingUhCourseCode
44        | Code::MissingEctsCredits => Class::PermanentNeedsConfig,
45        Code::AcceptorNotFound
46        | Code::SisuValidationFailed
47        | Code::Misregistered
48        | Code::RetryWindowExpired
49        | Code::Unknown => Class::PermanentNeedsAdmin,
50    }
51}
52
53/// What one `code` says about the row that carries it. Every view below narrows this.
54#[derive(Debug, PartialEq, Eq, Clone, Copy)]
55pub enum WireOutcome {
56    /// The answer settles the row, in this state.
57    Settled(CreditRegistrationState),
58    /// A real answer that decides nothing: the lookup endpoints' success codes, and verify's
59    /// `notRegistered`, which only means Sisu has not finished yet.
60    Unsettled,
61    Failure(CreditRegistrationErrorCode),
62}
63
64/// The contract's own reading of a `code`, before any hardening of ours.
65///
66/// An unrecognised code is a failure rather than an error, since Suotar may add codes; which of
67/// them are even recoverable is [`retryability`].
68fn wire_outcome(code: &str) -> WireOutcome {
69    use CreditRegistrationErrorCode as Code;
70    use CreditRegistrationState as State;
71    match code {
72        // The four import answers that end a row, and verify's `registered`, which is the same
73        // answer arriving later.
74        "sent" => WireOutcome::Settled(State::AwaitingVerification),
75        "registered" => WireOutcome::Settled(State::Registered),
76        "duplicateAttainment" => WireOutcome::Settled(State::Duplicate),
77        "notImprovedAttainment" => WireOutcome::Settled(State::NotImproved),
78        "personFound" | "enrolmentFound" | "found" | "enrolmentsListed" | "notRegistered" => {
79            WireOutcome::Unsettled
80        }
81        "personNotFound" => WireOutcome::Failure(Code::PersonNotFound),
82        "courseCodeNotFound" => WireOutcome::Failure(Code::CourseCodeNotFound),
83        "enrolmentNotFound" => WireOutcome::Failure(Code::EnrolmentNotFound),
84        "enrolmentNotAccepted" => WireOutcome::Failure(Code::EnrolmentNotAccepted),
85        "invalidGradeForGradeScale" => WireOutcome::Failure(Code::InvalidGradeForGradeScale),
86        "courseNotAllowed" => WireOutcome::Failure(Code::CourseNotAllowed),
87        "invalidCredits" => WireOutcome::Failure(Code::InvalidCredits),
88        "studyRightNotValid" => WireOutcome::Failure(Code::StudyRightNotValid),
89        "acceptorNotFound" => WireOutcome::Failure(Code::AcceptorNotFound),
90        "sisuValidationFailed" => WireOutcome::Failure(Code::SisuValidationFailed),
91        "sisuTimeout" => WireOutcome::Failure(Code::SisuTimeout),
92        "misregistered" => WireOutcome::Failure(Code::Misregistered),
93        "unauthorized" => WireOutcome::Failure(Code::Unauthorized),
94        "malformedRequest" => WireOutcome::Failure(Code::MalformedRequest),
95        "sisuTemporarilyUnavailable" => WireOutcome::Failure(Code::SisuTemporarilyUnavailable),
96        _ => WireOutcome::Failure(Code::Unknown),
97    }
98}
99
100/// [`wire_outcome`] hardened for the endpoint the code arrived on. Every caller that has an
101/// endpoint to name goes through here.
102pub fn outcome_of(endpoint: SuotarEndpoint, code: &str) -> WireOutcome {
103    let outcome = wire_outcome(code);
104    // Import's contract has no per-item transient, so one arriving there is no evidence that
105    // nothing was created; retrying it could put a second attainment on a transcript.
106    if endpoint == SuotarEndpoint::ImportAttainments
107        && outcome == WireOutcome::Failure(CreditRegistrationErrorCode::SisuTemporarilyUnavailable)
108    {
109        return WireOutcome::Failure(CreditRegistrationErrorCode::SisuTimeout);
110    }
111    outcome
112}
113
114/// The class the contract gives a wire code, before any hardening of ours, which is what the mock's
115/// fault validator needs.
116pub fn wire_code_retryability(code: &str) -> Option<Retryability> {
117    match wire_outcome(code) {
118        WireOutcome::Failure(code) => Some(retryability(code)),
119        _ => None,
120    }
121}
122
123pub fn is_retryable_transient_wire_code(code: &str) -> bool {
124    wire_code_retryability(code) == Some(Retryability::RetryableTransient)
125}
126
127/// Suotar's per-item `code` as a ledger error code, hardened for the endpoint it arrived on.
128/// `None` where the code names no failure to record.
129pub fn map_code(endpoint: SuotarEndpoint, code: &str) -> Option<CreditRegistrationErrorCode> {
130    match outcome_of(endpoint, code) {
131        WireOutcome::Failure(code) => Some(code),
132        _ => None,
133    }
134}
135
136/// The state a `code` settles a row in, or `None` where it settles nothing.
137pub fn settled_state(endpoint: SuotarEndpoint, code: &str) -> Option<CreditRegistrationState> {
138    match outcome_of(endpoint, code) {
139        WireOutcome::Settled(state) => Some(state),
140        _ => None,
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use CreditRegistrationErrorCode as Code;
148    use Retryability as Class;
149
150    /// Pins each code to its documented class explicitly, so a future edit that moves a code
151    /// between match arms fails here even though the match stays exhaustive to the compiler.
152    #[test]
153    fn retryability_matches_the_documented_class_for_every_code() {
154        let cases = [
155            (Code::SisuTemporarilyUnavailable, Class::RetryableTransient),
156            (Code::TransportError, Class::RetryableTransient),
157            (Code::Unauthorized, Class::RetryableTransient),
158            (Code::MalformedRequest, Class::RetryableTransient),
159            (Code::UnexpectedResponse, Class::RetryableTransient),
160            (Code::SisuTimeout, Class::VerifyOnly),
161            (Code::PersonNotFound, Class::PermanentNeedsStudent),
162            (Code::EnrolmentNotFound, Class::PermanentNeedsStudent),
163            (Code::EnrolmentNotAccepted, Class::PermanentNeedsStudent),
164            (Code::StudyRightNotValid, Class::PermanentNeedsStudent),
165            (Code::CourseCodeNotFound, Class::PermanentNeedsConfig),
166            (Code::CourseNotAllowed, Class::PermanentNeedsConfig),
167            (Code::InvalidGradeForGradeScale, Class::PermanentNeedsConfig),
168            (Code::InvalidCredits, Class::PermanentNeedsConfig),
169            (Code::NoGradeScaleMapping, Class::PermanentNeedsConfig),
170            (Code::MissingUhCourseCode, Class::PermanentNeedsConfig),
171            (Code::MissingEctsCredits, Class::PermanentNeedsConfig),
172            (Code::AcceptorNotFound, Class::PermanentNeedsAdmin),
173            (Code::SisuValidationFailed, Class::PermanentNeedsAdmin),
174            (Code::Misregistered, Class::PermanentNeedsAdmin),
175            (Code::RetryWindowExpired, Class::PermanentNeedsAdmin),
176            (Code::Unknown, Class::PermanentNeedsAdmin),
177        ];
178        assert_eq!(
179            cases.len(),
180            CreditRegistrationErrorCode::ALL.len(),
181            "every code must be covered"
182        );
183        for (code, expected) in cases {
184            assert_eq!(retryability(code), expected, "{code:?}");
185        }
186    }
187
188    /// Import's hardening must not reach the wire class, or the mock's fault validator would stop
189    /// refusing the one combination it guards.
190    #[test]
191    fn the_wire_class_of_the_transient_code_ignores_imports_hardening() {
192        assert!(is_retryable_transient_wire_code(
193            "sisuTemporarilyUnavailable"
194        ));
195        assert_eq!(
196            map_code(
197                SuotarEndpoint::ImportAttainments,
198                "sisuTemporarilyUnavailable"
199            )
200            .map(retryability),
201            Some(Class::VerifyOnly)
202        );
203    }
204
205    #[test]
206    fn a_code_that_names_no_failure_has_no_wire_class() {
207        assert_eq!(wire_code_retryability("registered"), None);
208        assert_eq!(wire_code_retryability("notRegistered"), None);
209    }
210
211    #[test]
212    fn every_documented_error_code_maps() {
213        use CreditRegistrationErrorCode as Code;
214        let cases = [
215            (
216                SuotarEndpoint::ResolvePersons,
217                "personNotFound",
218                Code::PersonNotFound,
219            ),
220            (
221                SuotarEndpoint::ResolvePersons,
222                "sisuTemporarilyUnavailable",
223                Code::SisuTemporarilyUnavailable,
224            ),
225            (
226                SuotarEndpoint::ResolveEnrolments,
227                "personNotFound",
228                Code::PersonNotFound,
229            ),
230            (
231                SuotarEndpoint::ResolveEnrolments,
232                "courseCodeNotFound",
233                Code::CourseCodeNotFound,
234            ),
235            (
236                SuotarEndpoint::ResolveEnrolments,
237                "enrolmentNotFound",
238                Code::EnrolmentNotFound,
239            ),
240            (
241                SuotarEndpoint::ResolveEnrolments,
242                "enrolmentNotAccepted",
243                Code::EnrolmentNotAccepted,
244            ),
245            (
246                SuotarEndpoint::ImportAttainments,
247                "invalidGradeForGradeScale",
248                Code::InvalidGradeForGradeScale,
249            ),
250            (
251                SuotarEndpoint::ImportAttainments,
252                "courseNotAllowed",
253                Code::CourseNotAllowed,
254            ),
255            (
256                SuotarEndpoint::ImportAttainments,
257                "invalidCredits",
258                Code::InvalidCredits,
259            ),
260            (
261                SuotarEndpoint::ImportAttainments,
262                "studyRightNotValid",
263                Code::StudyRightNotValid,
264            ),
265            (
266                SuotarEndpoint::ImportAttainments,
267                "acceptorNotFound",
268                Code::AcceptorNotFound,
269            ),
270            (
271                SuotarEndpoint::ImportAttainments,
272                "sisuValidationFailed",
273                Code::SisuValidationFailed,
274            ),
275            (
276                SuotarEndpoint::ImportAttainments,
277                "sisuTimeout",
278                Code::SisuTimeout,
279            ),
280            (
281                SuotarEndpoint::VerifyAttainments,
282                "misregistered",
283                Code::Misregistered,
284            ),
285            (
286                SuotarEndpoint::VerifyAttainments,
287                "sisuTemporarilyUnavailable",
288                Code::SisuTemporarilyUnavailable,
289            ),
290            (
291                SuotarEndpoint::ListByCourse,
292                "courseCodeNotFound",
293                Code::CourseCodeNotFound,
294            ),
295            (
296                SuotarEndpoint::ResolvePersons,
297                "unauthorized",
298                Code::Unauthorized,
299            ),
300            (
301                SuotarEndpoint::ResolvePersons,
302                "malformedRequest",
303                Code::MalformedRequest,
304            ),
305        ];
306        for (endpoint, code, expected) in cases {
307            assert_eq!(
308                map_code(endpoint, code),
309                Some(expected),
310                "{code} on {endpoint:?}"
311            );
312        }
313    }
314
315    #[test]
316    fn no_code_that_needs_no_recording_becomes_an_error() {
317        for (endpoint, code) in [
318            (SuotarEndpoint::ResolvePersons, "personFound"),
319            (SuotarEndpoint::ResolveEnrolments, "enrolmentFound"),
320            (SuotarEndpoint::ImportAttainments, "sent"),
321            (SuotarEndpoint::ImportAttainments, "registered"),
322            (SuotarEndpoint::ImportAttainments, "duplicateAttainment"),
323            (SuotarEndpoint::ImportAttainments, "notImprovedAttainment"),
324            (SuotarEndpoint::VerifyAttainments, "registered"),
325            (SuotarEndpoint::ProductAccessTokens, "found"),
326            (SuotarEndpoint::ListByCourse, "enrolmentsListed"),
327            (SuotarEndpoint::VerifyAttainments, "notRegistered"),
328        ] {
329            assert_eq!(map_code(endpoint, code), None, "{code} on {endpoint:?}");
330        }
331    }
332
333    #[test]
334    fn an_item_level_transient_on_import_is_uncertain_rather_than_retryable() {
335        assert_eq!(
336            map_code(
337                SuotarEndpoint::ImportAttainments,
338                "sisuTemporarilyUnavailable"
339            ),
340            Some(CreditRegistrationErrorCode::SisuTimeout)
341        );
342    }
343
344    /// The success half of the vocabulary, which decides where a row ends up rather than what went
345    /// wrong with it.
346    #[test]
347    fn every_code_that_settles_a_row_names_the_state_it_settles_it_in() {
348        use CreditRegistrationState as State;
349        for (code, expected) in [
350            ("sent", State::AwaitingVerification),
351            ("registered", State::Registered),
352            ("duplicateAttainment", State::Duplicate),
353            ("notImprovedAttainment", State::NotImproved),
354        ] {
355            assert_eq!(
356                settled_state(SuotarEndpoint::ImportAttainments, code),
357                Some(expected),
358                "{code}"
359            );
360        }
361        assert_eq!(
362            settled_state(SuotarEndpoint::VerifyAttainments, "registered"),
363            Some(State::Registered)
364        );
365        for code in ["notRegistered", "personFound", "sisuTimeout"] {
366            assert_eq!(
367                settled_state(SuotarEndpoint::VerifyAttainments, code),
368                None,
369                "{code}"
370            );
371        }
372    }
373
374    #[test]
375    fn a_code_suotar_adds_later_maps_to_unknown_rather_than_failing() {
376        assert_eq!(
377            map_code(
378                SuotarEndpoint::VerifyAttainments,
379                "somethingSuotarAddedLater"
380            ),
381            Some(CreditRegistrationErrorCode::Unknown)
382        );
383    }
384}