1use 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#[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#[derive(Debug, Clone, PartialEq)]
30pub struct Outcome {
31 pub to_state: CreditRegistrationState,
32 pub error_code: Option<CreditRegistrationErrorCode>,
33 pub needs_admin_attention: Option<bool>,
35 pub delay_secs: Option<i64>,
37 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
76pub fn submission_uncertain() -> Outcome {
79 Outcome::to(CreditRegistrationState::SubmissionUncertain)
80 .with_code(CreditRegistrationErrorCode::SisuTimeout)
81 .after(UNCERTAIN_RECHECK_SECS)
82}
83
84pub fn submit_error_outcome(
87 endpoint: SuotarEndpoint,
88 code: CreditRegistrationErrorCode,
89 facts: &RowFacts,
90) -> Outcome {
91 use CreditRegistrationErrorCode as Code;
92 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 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
123pub 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
138pub 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
153pub 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
165pub 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
178pub 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
212fn 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
237pub fn import_success_state(code: &str) -> Option<CreditRegistrationState> {
239 settled_state(SuotarEndpoint::ImportAttainments, code)
240}
241
242pub 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
252pub fn missing_context_outcome(facts: &RowFacts) -> Outcome {
256 retry_or_expire(
257 CreditRegistrationErrorCode::Unknown,
258 SuotarEndpoint::ResolveEnrolments,
259 facts,
260 )
261}
262
263pub 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 #[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 #[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 #[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 #[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}