Skip to main content

headless_lms_models/library/credit_registration/
outcomes.rs

1//! What one Suotar answer does to one ledger row, decided apart from the phases that apply it.
2//! No outcome here may return a state that leads back to `import`: a row whose import may have
3//! landed must never be sent again.
4
5use headless_lms_utils::error::util_error::SuotarErrorVariant;
6
7use crate::credit_registrations::{CreditRegistrationErrorCode, CreditRegistrationState};
8use crate::prelude::*;
9use crate::suotar_api_calls::SuotarEndpoint;
10
11use super::backoff::{
12    NO_USABLE_ENROLMENT_RECHECK_SECS, UNCERTAIN_MAX_CHECKS, UNCERTAIN_RECHECK_SECS,
13    VERIFY_FIRST_DELAY_SECS, VERIFY_GIVE_UP_POLL_SECS, next_attempt_at, submit_backoff_secs,
14    submit_window_expired, verify_backoff_secs, verify_window_expired,
15};
16use super::classification::{Retryability, retryability, settled_state};
17
18/// The row's scheduling history, which is all these decisions need from it.
19#[derive(Debug, Clone, PartialEq)]
20pub struct RowFacts {
21    pub now: DateTime<Utc>,
22    pub first_failed_at: Option<DateTime<Utc>>,
23    pub submit_retry_count: i32,
24    pub verify_attempt_count: i32,
25    pub submitted_at: Option<DateTime<Utc>>,
26}
27
28/// What the phase writes for this row.
29#[derive(Debug, Clone, PartialEq)]
30pub struct Outcome {
31    pub to_state: CreditRegistrationState,
32    pub error_code: Option<CreditRegistrationErrorCode>,
33    /// `None` leaves the flag as it was, so a retry keeps an operator's earlier verdict.
34    pub needs_admin_attention: Option<bool>,
35    /// Seconds to wait before the row may be claimed again.
36    pub delay_secs: Option<i64>,
37    /// Set when Suotar says the stored number names nobody, so it is wrong wherever we hold it.
38    pub drop_verified_student_number: bool,
39    pub increment_submit_retry_count: bool,
40}
41
42impl Outcome {
43    pub fn to(to_state: CreditRegistrationState) -> Self {
44        Self {
45            to_state,
46            error_code: None,
47            needs_admin_attention: None,
48            delay_secs: None,
49            drop_verified_student_number: false,
50            increment_submit_retry_count: false,
51        }
52    }
53
54    fn with_code(self, error_code: CreditRegistrationErrorCode) -> Self {
55        Self {
56            error_code: Some(error_code),
57            ..self
58        }
59    }
60
61    fn needing_admin(self) -> Self {
62        Self {
63            needs_admin_attention: Some(true),
64            ..self
65        }
66    }
67
68    fn after(self, delay_secs: i64) -> Self {
69        Self {
70            delay_secs: Some(delay_secs),
71            ..self
72        }
73    }
74}
75
76/// The one state whose only way out is `verify`; every path that cannot prove nothing was created
77/// ends here.
78pub fn submission_uncertain() -> Outcome {
79    Outcome::to(CreditRegistrationState::SubmissionUncertain)
80        .with_code(CreditRegistrationErrorCode::SisuTimeout)
81        .after(UNCERTAIN_RECHECK_SECS)
82}
83
84/// A per-item error on the calls leading towards a submission. Only `import` creates attainments,
85/// so anything uncertain there is verify-only, while the same code on `resolve-enrolments` retries.
86pub fn submit_error_outcome(
87    endpoint: SuotarEndpoint,
88    code: CreditRegistrationErrorCode,
89    facts: &RowFacts,
90) -> Outcome {
91    use CreditRegistrationErrorCode as Code;
92    // An unclassifiable answer is no evidence that nothing was created, and an admin retry from
93    // `failed_permanent` would then be a second submission.
94    if endpoint == SuotarEndpoint::ImportAttainments && code == Code::Unknown {
95        return submission_uncertain();
96    }
97    match retryability(code) {
98        Retryability::VerifyOnly if endpoint == SuotarEndpoint::ImportAttainments => {
99            submission_uncertain()
100        }
101        Retryability::VerifyOnly | Retryability::RetryableTransient => {
102            retry_or_expire(code, endpoint, facts)
103        }
104        Retryability::PermanentNeedsStudent => match code {
105            // Dropping the number puts the student back in the linking flow, the only thing that
106            // can fix this, and the row heals itself once they link a working one.
107            Code::PersonNotFound => Outcome {
108                drop_verified_student_number: true,
109                ..Outcome::to(CreditRegistrationState::Pending).with_code(code)
110            },
111            _ => Outcome::to(CreditRegistrationState::NoUsableEnrolment)
112                .with_code(code)
113                .after(NO_USABLE_ENROLMENT_RECHECK_SECS),
114        },
115        Retryability::PermanentNeedsConfig | Retryability::PermanentNeedsAdmin => {
116            Outcome::to(CreditRegistrationState::FailedPermanent)
117                .with_code(code)
118                .needing_admin()
119        }
120    }
121}
122
123/// A per-item error while polling `verify`. Never a failure: the attainment may exist, and a row
124/// marked failed invites a second submission later.
125pub fn verify_error_outcome(
126    state: CreditRegistrationState,
127    code: CreditRegistrationErrorCode,
128    facts: &RowFacts,
129) -> Outcome {
130    if code == CreditRegistrationErrorCode::Misregistered {
131        return Outcome::to(CreditRegistrationState::Misregistered)
132            .with_code(code)
133            .needing_admin();
134    }
135    verify_not_registered_outcome(state, facts)
136}
137
138/// A `verify` poll that Sisu has nothing to say about yet.
139pub fn verify_not_registered_outcome(state: CreditRegistrationState, facts: &RowFacts) -> Outcome {
140    let expired = verify_window_expired(facts.submitted_at, facts.now);
141    let outcome = Outcome::to(state).after(if expired {
142        VERIFY_GIVE_UP_POLL_SECS
143    } else {
144        verify_backoff_secs(facts.verify_attempt_count)
145    });
146    if expired {
147        outcome.needing_admin()
148    } else {
149        outcome
150    }
151}
152
153/// A fruitless look through `existingAttainments` for an attainment we may have created. After
154/// enough of them a human checks Sisu by hand; the row still never resubmits.
155pub fn uncertain_recheck_outcome(facts: &RowFacts) -> Outcome {
156    let outcome =
157        Outcome::to(CreditRegistrationState::SubmissionUncertain).after(UNCERTAIN_RECHECK_SECS);
158    if facts.verify_attempt_count >= UNCERTAIN_MAX_CHECKS {
159        outcome.needing_admin()
160    } else {
161        outcome
162    }
163}
164
165/// The outcome for every row of a batch Suotar rejected as a whole. On `import` all that matters is
166/// whether the request could have been acted on: a connection that never opened proves it was not.
167pub fn request_level_outcome(
168    endpoint: SuotarEndpoint,
169    variant: SuotarErrorVariant,
170    facts: &RowFacts,
171) -> Outcome {
172    if endpoint == SuotarEndpoint::ImportAttainments && variant.outcome_may_have_landed() {
173        return submission_uncertain();
174    }
175    retry_or_expire(request_level_code(variant), endpoint, facts)
176}
177
178/// An item we sent and Suotar did not answer. On `import` that leaves us where a timeout does;
179/// elsewhere the call simply did not happen for that row.
180pub fn unanswered_item_outcome(
181    endpoint: SuotarEndpoint,
182    state: CreditRegistrationState,
183    facts: &RowFacts,
184) -> Outcome {
185    if endpoint == SuotarEndpoint::ImportAttainments {
186        return submission_uncertain();
187    }
188    if endpoint == SuotarEndpoint::VerifyAttainments {
189        return verify_not_registered_outcome(state, facts);
190    }
191    retry_or_expire(
192        CreditRegistrationErrorCode::UnexpectedResponse,
193        endpoint,
194        facts,
195    )
196}
197
198fn request_level_code(variant: SuotarErrorVariant) -> CreditRegistrationErrorCode {
199    match variant {
200        SuotarErrorVariant::Unauthorized => CreditRegistrationErrorCode::Unauthorized,
201        SuotarErrorVariant::MalformedRequest => CreditRegistrationErrorCode::MalformedRequest,
202        SuotarErrorVariant::Deserialization => CreditRegistrationErrorCode::UnexpectedResponse,
203        SuotarErrorVariant::ServerError | SuotarErrorVariant::RequestLevelError => {
204            CreditRegistrationErrorCode::SisuTemporarilyUnavailable
205        }
206        SuotarErrorVariant::TransportNotDelivered | SuotarErrorVariant::TransportUnknown => {
207            CreditRegistrationErrorCode::TransportError
208        }
209    }
210}
211
212/// Retryable until the row has been failing for a week, and then a support case rather than an
213/// endless one.
214fn retry_or_expire(
215    code: CreditRegistrationErrorCode,
216    endpoint: SuotarEndpoint,
217    facts: &RowFacts,
218) -> Outcome {
219    if submit_window_expired(facts.first_failed_at, facts.now) {
220        return Outcome::to(CreditRegistrationState::FailedPermanent)
221            .with_code(CreditRegistrationErrorCode::RetryWindowExpired)
222            .needing_admin();
223    }
224    let delay = if endpoint == SuotarEndpoint::VerifyAttainments {
225        verify_backoff_secs(facts.verify_attempt_count)
226    } else {
227        submit_backoff_secs(facts.submit_retry_count)
228    };
229    Outcome {
230        increment_submit_retry_count: endpoint != SuotarEndpoint::VerifyAttainments,
231        ..Outcome::to(CreditRegistrationState::FailedRetryable)
232            .with_code(code)
233            .after(delay)
234    }
235}
236
237/// Where a successful `import` item lands the row; `sent` means Sisu has not answered yet.
238pub fn import_success_state(code: &str) -> Option<CreditRegistrationState> {
239    settled_state(SuotarEndpoint::ImportAttainments, code)
240}
241
242/// What an `import` answer that settled the row does to it, including the wait Sisu needs before
243/// the first verify poll can find anything.
244pub fn import_success_outcome(state: CreditRegistrationState) -> Outcome {
245    let outcome = Outcome::to(state);
246    if state == CreditRegistrationState::AwaitingVerification {
247        return outcome.after(VERIFY_FIRST_DELAY_SECS);
248    }
249    outcome
250}
251
252/// A row `resolve-enrolments` claimed but has no completion or module to ask about. Retryable like
253/// any other failure, and it accrues retry age, so a row whose context never comes back expires
254/// instead of being repolled forever.
255pub fn missing_context_outcome(facts: &RowFacts) -> Outcome {
256    retry_or_expire(
257        CreditRegistrationErrorCode::Unknown,
258        SuotarEndpoint::ResolveEnrolments,
259        facts,
260    )
261}
262
263/// How long a verify poll pushes the row out of reach while its request is out, so a concurrent
264/// iteration cannot poll it twice. The poll's own outcome overwrites this.
265pub fn verify_poll_lease_until(now: DateTime<Utc>, attempt: i32) -> DateTime<Utc> {
266    next_attempt_at(now, verify_backoff_secs(attempt))
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272    use CreditRegistrationErrorCode as Code;
273    use CreditRegistrationState as State;
274
275    fn facts() -> RowFacts {
276        RowFacts {
277            now: Utc::now(),
278            first_failed_at: None,
279            submit_retry_count: 0,
280            verify_attempt_count: 0,
281            submitted_at: None,
282        }
283    }
284
285    fn import(code: Code) -> Outcome {
286        submit_error_outcome(SuotarEndpoint::ImportAttainments, code, &facts())
287    }
288
289    fn resolve(code: Code) -> Outcome {
290        submit_error_outcome(SuotarEndpoint::ResolveEnrolments, code, &facts())
291    }
292
293    #[test]
294    fn every_error_code_has_an_import_outcome_that_never_resends() {
295        for code in CreditRegistrationErrorCode::ALL {
296            let outcome = import(code);
297            assert!(
298                !matches!(
299                    outcome.to_state,
300                    State::Submitting | State::CheckingEnrolment
301                ),
302                "{code:?} would put the row back in front of import"
303            );
304        }
305    }
306
307    /// Pins each code to the exact state import() routes it to, so a future edit that misroutes one
308    /// code fails here even though the match stays exhaustive to the compiler.
309    #[test]
310    fn import_routes_every_code_to_its_documented_state() {
311        let cases = [
312            (Code::SisuTemporarilyUnavailable, State::FailedRetryable),
313            (Code::TransportError, State::FailedRetryable),
314            (Code::Unauthorized, State::FailedRetryable),
315            (Code::MalformedRequest, State::FailedRetryable),
316            (Code::UnexpectedResponse, State::FailedRetryable),
317            (Code::SisuTimeout, State::SubmissionUncertain),
318            (Code::PersonNotFound, State::Pending),
319            (Code::EnrolmentNotFound, State::NoUsableEnrolment),
320            (Code::EnrolmentNotAccepted, State::NoUsableEnrolment),
321            (Code::StudyRightNotValid, State::NoUsableEnrolment),
322            (Code::CourseCodeNotFound, State::FailedPermanent),
323            (Code::CourseNotAllowed, State::FailedPermanent),
324            (Code::InvalidGradeForGradeScale, State::FailedPermanent),
325            (Code::InvalidCredits, State::FailedPermanent),
326            (Code::NoGradeScaleMapping, State::FailedPermanent),
327            (Code::MissingUhCourseCode, State::FailedPermanent),
328            (Code::MissingEctsCredits, State::FailedPermanent),
329            (Code::AcceptorNotFound, State::FailedPermanent),
330            (Code::SisuValidationFailed, State::FailedPermanent),
331            (Code::Misregistered, State::FailedPermanent),
332            (Code::RetryWindowExpired, State::FailedPermanent),
333            (Code::Unknown, State::SubmissionUncertain),
334        ];
335        assert_eq!(
336            cases.len(),
337            CreditRegistrationErrorCode::ALL.len(),
338            "every code must be covered"
339        );
340        for (code, expected) in cases {
341            assert_eq!(import(code).to_state, expected, "{code:?}");
342        }
343    }
344
345    /// Each of these is a refusal of the item before Sisu saw it, so nothing was created. Adding to
346    /// the list is a decision about a real transcript.
347    #[test]
348    fn the_import_answers_that_allow_another_attempt_are_only_refusals() {
349        let resendable: Vec<Code> = CreditRegistrationErrorCode::ALL
350            .into_iter()
351            .filter(|code| import(*code).to_state == State::FailedRetryable)
352            .collect();
353        assert_eq!(
354            resendable,
355            vec![
356                Code::SisuTemporarilyUnavailable,
357                Code::Unauthorized,
358                Code::MalformedRequest,
359                Code::TransportError,
360                Code::UnexpectedResponse,
361            ]
362        );
363        assert_eq!(
364            super::super::classification::map_code(
365                SuotarEndpoint::ImportAttainments,
366                "sisuTemporarilyUnavailable"
367            ),
368            Some(Code::SisuTimeout)
369        );
370    }
371
372    /// An admin retry from `failed_permanent` would send an import whose outcome nobody knows.
373    #[test]
374    fn an_import_answer_we_cannot_classify_is_uncertain_rather_than_failed() {
375        assert_eq!(import(Code::Unknown).to_state, State::SubmissionUncertain);
376        assert_eq!(resolve(Code::Unknown).to_state, State::FailedPermanent);
377    }
378
379    #[test]
380    fn a_timeout_is_uncertain_on_import_and_retryable_on_resolve() {
381        assert_eq!(
382            import(Code::SisuTimeout).to_state,
383            State::SubmissionUncertain
384        );
385        assert_eq!(resolve(Code::SisuTimeout).to_state, State::FailedRetryable);
386    }
387
388    #[test]
389    fn a_person_suotar_does_not_know_costs_the_stored_student_number() {
390        let outcome = import(Code::PersonNotFound);
391        assert!(outcome.drop_verified_student_number);
392        assert_eq!(outcome.to_state, State::Pending);
393        for code in CreditRegistrationErrorCode::ALL {
394            if code != Code::PersonNotFound {
395                assert!(!import(code).drop_verified_student_number, "{code:?}");
396            }
397        }
398    }
399
400    #[test]
401    fn a_config_error_asks_for_a_human_and_a_transient_one_does_not() {
402        assert_eq!(
403            import(Code::InvalidGradeForGradeScale).needs_admin_attention,
404            Some(true)
405        );
406        assert_eq!(
407            import(Code::SisuTemporarilyUnavailable).needs_admin_attention,
408            None
409        );
410    }
411
412    #[test]
413    fn a_row_that_has_been_failing_for_a_week_stops_being_retried() {
414        let facts = RowFacts {
415            first_failed_at: Some(Utc::now() - chrono::Duration::days(8)),
416            ..facts()
417        };
418        let outcome = submit_error_outcome(
419            SuotarEndpoint::ResolveEnrolments,
420            Code::SisuTemporarilyUnavailable,
421            &facts,
422        );
423        assert_eq!(outcome.to_state, State::FailedPermanent);
424        assert_eq!(outcome.error_code, Some(Code::RetryWindowExpired));
425    }
426
427    #[test]
428    fn an_expired_window_does_not_override_an_uncertain_import() {
429        let facts = RowFacts {
430            first_failed_at: Some(Utc::now() - chrono::Duration::days(8)),
431            ..facts()
432        };
433        assert_eq!(
434            submit_error_outcome(SuotarEndpoint::ImportAttainments, Code::SisuTimeout, &facts)
435                .to_state,
436            State::SubmissionUncertain
437        );
438    }
439
440    #[test]
441    fn only_a_request_that_may_have_reached_business_logic_leaves_an_import_batch_uncertain() {
442        let facts = facts();
443        for variant in [
444            SuotarErrorVariant::ServerError,
445            SuotarErrorVariant::TransportUnknown,
446            SuotarErrorVariant::Deserialization,
447        ] {
448            assert_eq!(
449                request_level_outcome(SuotarEndpoint::ImportAttainments, variant, &facts).to_state,
450                State::SubmissionUncertain,
451                "{variant:?}"
452            );
453        }
454        for variant in [
455            SuotarErrorVariant::TransportNotDelivered,
456            SuotarErrorVariant::Unauthorized,
457            SuotarErrorVariant::MalformedRequest,
458            SuotarErrorVariant::RequestLevelError,
459        ] {
460            assert_eq!(
461                request_level_outcome(SuotarEndpoint::ImportAttainments, variant, &facts).to_state,
462                State::FailedRetryable,
463                "{variant:?}"
464            );
465        }
466    }
467
468    #[test]
469    fn a_request_level_failure_elsewhere_is_always_a_plain_retry() {
470        let facts = facts();
471        for variant in [
472            SuotarErrorVariant::ServerError,
473            SuotarErrorVariant::TransportUnknown,
474            SuotarErrorVariant::Unauthorized,
475        ] {
476            assert_eq!(
477                request_level_outcome(SuotarEndpoint::ResolveEnrolments, variant, &facts).to_state,
478                State::FailedRetryable,
479                "{variant:?}"
480            );
481        }
482    }
483
484    #[test]
485    fn an_import_item_suotar_never_answered_is_uncertain() {
486        assert_eq!(
487            unanswered_item_outcome(
488                SuotarEndpoint::ImportAttainments,
489                State::Submitting,
490                &facts()
491            )
492            .to_state,
493            State::SubmissionUncertain
494        );
495    }
496
497    #[test]
498    fn an_unanswered_verify_item_just_polls_again() {
499        let outcome = unanswered_item_outcome(
500            SuotarEndpoint::VerifyAttainments,
501            State::AwaitingVerification,
502            &facts(),
503        );
504        assert_eq!(outcome.to_state, State::AwaitingVerification);
505        assert!(outcome.delay_secs.is_some());
506    }
507
508    #[test]
509    fn verify_never_fails_a_row() {
510        let facts = RowFacts {
511            submitted_at: Some(Utc::now() - chrono::Duration::days(30)),
512            ..facts()
513        };
514        for code in CreditRegistrationErrorCode::ALL {
515            let outcome = verify_error_outcome(State::AwaitingVerification, code, &facts);
516            assert!(
517                !matches!(
518                    outcome.to_state,
519                    State::FailedPermanent | State::FailedRetryable
520                ),
521                "{code:?}"
522            );
523        }
524    }
525
526    #[test]
527    fn a_reversal_in_sisu_needs_a_human() {
528        let outcome =
529            verify_error_outcome(State::AwaitingVerification, Code::Misregistered, &facts());
530        assert_eq!(outcome.to_state, State::Misregistered);
531        assert_eq!(outcome.needs_admin_attention, Some(true));
532    }
533
534    #[test]
535    fn an_expired_verify_window_slows_down_and_asks_for_a_human() {
536        let facts = RowFacts {
537            submitted_at: Some(Utc::now() - chrono::Duration::days(20)),
538            verify_attempt_count: 40,
539            ..facts()
540        };
541        let outcome = verify_not_registered_outcome(State::AwaitingVerification, &facts);
542        assert_eq!(outcome.to_state, State::AwaitingVerification);
543        assert_eq!(outcome.needs_admin_attention, Some(true));
544        assert_eq!(outcome.delay_secs, Some(VERIFY_GIVE_UP_POLL_SECS));
545    }
546
547    /// A row the phase can build no request for has to accrue retry age like any other failure, or
548    /// nothing ever stops it being claimed again.
549    #[test]
550    fn a_row_with_nothing_to_submit_for_ages_out_of_the_retry_window() {
551        let fresh = missing_context_outcome(&facts());
552        assert_eq!(fresh.to_state, State::FailedRetryable);
553        assert!(fresh.increment_submit_retry_count);
554
555        let old = missing_context_outcome(&RowFacts {
556            first_failed_at: Some(Utc::now() - chrono::Duration::days(8)),
557            ..facts()
558        });
559        assert_eq!(old.to_state, State::FailedPermanent);
560        assert_eq!(old.error_code, Some(Code::RetryWindowExpired));
561    }
562
563    #[test]
564    fn an_uncertain_row_asks_for_a_human_only_after_the_documented_checks() {
565        let before = uncertain_recheck_outcome(&RowFacts {
566            verify_attempt_count: UNCERTAIN_MAX_CHECKS - 1,
567            ..facts()
568        });
569        assert_eq!(before.needs_admin_attention, None);
570        assert_eq!(before.to_state, State::SubmissionUncertain);
571
572        let after = uncertain_recheck_outcome(&RowFacts {
573            verify_attempt_count: UNCERTAIN_MAX_CHECKS,
574            ..facts()
575        });
576        assert_eq!(after.needs_admin_attention, Some(true));
577        assert_eq!(after.to_state, State::SubmissionUncertain);
578    }
579}