Skip to main content

headless_lms_models/library/credit_registration/
classification.rs

1//! What may be done about a ledger error code: the one place retryability is decided. How long to
2//! wait is [`super::backoff`]; which Suotar codes map to which error code is the worker's Suotar
3//! adapter's.
4
5use utoipa::ToSchema;
6
7use crate::credit_registrations::CreditRegistrationErrorCode;
8use crate::prelude::*;
9
10/// What may be done about an error code. The class is the contract's, not the endpoint's: the
11/// import phase is the only place that upgrades a code to [`Retryability::VerifyOnly`].
12#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Hash, ToSchema)]
13#[serde(rename_all = "snake_case")]
14pub enum Retryability {
15    RetryableTransient,
16    /// The outcome is unknown and re-sending could duplicate it. Only `verify` may touch it.
17    VerifyOnly,
18    PermanentNeedsStudent,
19    PermanentNeedsAdmin,
20    PermanentNeedsConfig,
21}
22
23/// What may be done about a row carrying `code`.
24pub fn retryability(code: CreditRegistrationErrorCode) -> Retryability {
25    use CreditRegistrationErrorCode as Code;
26    use Retryability as Class;
27    match code {
28        Code::ServiceTemporarilyUnavailable | Code::TransportError => Class::RetryableTransient,
29        // Suotar found no trace of the submission, so it goes back to import.
30        Code::NotRegistered => Class::RetryableTransient,
31        // Our credentials or our request shape: re-queue the batch rather than blame its rows.
32        Code::Unauthorized | Code::MalformedRequest => Class::RetryableTransient,
33        // An answer we could not read says nothing about the row.
34        Code::UnexpectedResponse => Class::RetryableTransient,
35        Code::SisuTimeout => Class::VerifyOnly,
36        Code::PersonNotFound
37        | Code::EnrolmentNotFound
38        | Code::EnrolmentNotAccepted
39        | Code::StudyRightNotValid => Class::PermanentNeedsStudent,
40        Code::CourseCodeNotFound
41        | Code::CourseNotAllowed
42        | Code::InvalidGradeForGradeScale
43        | Code::GradeScaleMismatch
44        | Code::InvalidCredits
45        | Code::NoGradeScaleMapping
46        | Code::MissingUhCourseCode
47        | Code::MissingEctsCredits => Class::PermanentNeedsConfig,
48        Code::SisuValidationFailed
49        | Code::Misregistered
50        | Code::RetryWindowExpired
51        | Code::Unknown => Class::PermanentNeedsAdmin,
52    }
53}
54
55/// Whether Sisu answers an unchanged resend of a row carrying `code` the same way: the rejection
56/// is about what we sent or whom we sent it for, not about the moment it was sent.
57pub fn is_repeatable_rejection(code: CreditRegistrationErrorCode) -> bool {
58    use CreditRegistrationErrorCode as Code;
59    match retryability(code) {
60        Retryability::PermanentNeedsStudent | Retryability::PermanentNeedsConfig => true,
61        // The other admin codes are a timeout's aftermath, a reversal, or unclassified.
62        Retryability::PermanentNeedsAdmin => code == Code::SisuValidationFailed,
63        Retryability::RetryableTransient | Retryability::VerifyOnly => false,
64    }
65}
66
67/// The answers that would send the student off to enrol.
68pub fn is_enrolment_error(code: CreditRegistrationErrorCode) -> bool {
69    matches!(
70        code,
71        CreditRegistrationErrorCode::EnrolmentNotFound
72            | CreditRegistrationErrorCode::EnrolmentNotAccepted
73    )
74}
75
76/// Whether a row carrying `code` only awaits something outside the pipeline, so its answer is no
77/// failure: a submission Suotar cannot find yet, or an enrolment the student has not made.
78pub fn is_waiting_error(code: CreditRegistrationErrorCode) -> bool {
79    code == CreditRegistrationErrorCode::NotRegistered || is_enrolment_error(code)
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85    use CreditRegistrationErrorCode as Code;
86    use Retryability as Class;
87
88    /// Pins each code to its documented class explicitly, so a future edit that moves a code
89    /// between match arms fails here even though the match stays exhaustive to the compiler.
90    #[test]
91    fn retryability_matches_the_documented_class_for_every_code() {
92        let cases = [
93            (
94                Code::ServiceTemporarilyUnavailable,
95                Class::RetryableTransient,
96            ),
97            (Code::NotRegistered, Class::RetryableTransient),
98            (Code::TransportError, Class::RetryableTransient),
99            (Code::Unauthorized, Class::RetryableTransient),
100            (Code::MalformedRequest, Class::RetryableTransient),
101            (Code::UnexpectedResponse, Class::RetryableTransient),
102            (Code::SisuTimeout, Class::VerifyOnly),
103            (Code::PersonNotFound, Class::PermanentNeedsStudent),
104            (Code::EnrolmentNotFound, Class::PermanentNeedsStudent),
105            (Code::EnrolmentNotAccepted, Class::PermanentNeedsStudent),
106            (Code::StudyRightNotValid, Class::PermanentNeedsStudent),
107            (Code::CourseCodeNotFound, Class::PermanentNeedsConfig),
108            (Code::CourseNotAllowed, Class::PermanentNeedsConfig),
109            (Code::InvalidGradeForGradeScale, Class::PermanentNeedsConfig),
110            (Code::GradeScaleMismatch, Class::PermanentNeedsConfig),
111            (Code::InvalidCredits, Class::PermanentNeedsConfig),
112            (Code::NoGradeScaleMapping, Class::PermanentNeedsConfig),
113            (Code::MissingUhCourseCode, Class::PermanentNeedsConfig),
114            (Code::MissingEctsCredits, Class::PermanentNeedsConfig),
115            (Code::SisuValidationFailed, Class::PermanentNeedsAdmin),
116            (Code::Misregistered, Class::PermanentNeedsAdmin),
117            (Code::RetryWindowExpired, Class::PermanentNeedsAdmin),
118            (Code::Unknown, Class::PermanentNeedsAdmin),
119        ];
120        assert_eq!(
121            cases.len(),
122            CreditRegistrationErrorCode::ALL.len(),
123            "every code must be covered"
124        );
125        for (code, expected) in cases {
126            assert_eq!(retryability(code), expected, "{code:?}");
127        }
128    }
129}