From 37f3cc436aa1a1ef4e54b48f94b2ce93795f37b5 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Wed, 28 Oct 2026 18:14:13 +0800 Subject: [PATCH 1/3] Add retry wrapper and resilient login, booking and bookings read (step 9) retry(func, retries=7, description=None), RetriesExhaustedError, resilient login, book_class and get_my_bookings that re-read the card before a repeat and count a booking only when the page confirms it, --retries option, step-9 console image, seeded chaos tests at 50 percent failure and container tests against failing local fixtures. Task: MIL-007#1 Task: MIL-007#2 Task: MIL-007#3 Task: MIL-007#4 --- src/snack_and_lift/application/booking.py | 75 ++++- src/snack_and_lift/application/retry.py | 67 ++++ src/snack_and_lift/application/session.py | 25 ++ .../application/verification.py | 34 +- src/snack_and_lift/cli.py | 65 +++- src/snack_and_lift/constants.py | 25 ++ src/snack_and_lift/domain/errors.py | 4 + .../infrastructure/selenium_gym.py | 15 +- tests/e2e/fixtures/login.html | 11 + tests/e2e/fixtures/my_bookings.html | 10 +- tests/e2e/fixtures/schedule.html | 11 +- tests/e2e/test_selenium_gym.py | 98 +++++- tests/unit/fakes.py | 73 +++- tests/unit/test_application_verification.py | 10 +- tests/unit/test_booking.py | 4 +- tests/unit/test_cli.py | 21 +- tests/unit/test_constants.py | 4 + tests/unit/test_resilience.py | 316 ++++++++++++++++++ tests/unit/test_retry.py | 167 +++++++++ 19 files changed, 980 insertions(+), 55 deletions(-) create mode 100644 src/snack_and_lift/application/retry.py create mode 100644 tests/unit/test_resilience.py create mode 100644 tests/unit/test_retry.py diff --git a/src/snack_and_lift/application/booking.py b/src/snack_and_lift/application/booking.py index 230362a..096a294 100644 --- a/src/snack_and_lift/application/booking.py +++ b/src/snack_and_lift/application/booking.py @@ -3,10 +3,12 @@ """ import logging +import time from collections.abc import Callable from snack_and_lift import constants from snack_and_lift.application.ports import GymPort +from snack_and_lift.application.retry import Sleep, retry from snack_and_lift.domain.errors import GymError from snack_and_lift.domain.models import ButtonState, ClassCard, ClassResult from snack_and_lift.domain.rules import ( @@ -41,12 +43,68 @@ def book_class(gym: GymPort, card: ClassCard) -> ClassResult: return ClassResult(card, outcome) -def book_next_tuesday(gym: GymPort) -> ClassResult | None: +def _read_current_card(gym: GymPort, card: ClassCard) -> ClassCard: + """@brief Read a class again from the page, to see what an earlier attempt did. + + @param gym The gym site. + @param card The class, as it was read before. + @return The class with the state the page shows now. + @exception GymError The schedule could not be read or the class is no longer on it. + """ + for current in gym.list_class_cards(): + if current.card_id == card.card_id: + return current + raise GymError(constants.MESSAGE_CLASS_NOT_ON_SCHEDULE.format(card_id=card.card_id)) + + +def book_class_with_retry( + gym: GymPort, + card: ClassCard, + retries: int = constants.RETRY_COUNT, + sleep: Sleep = time.sleep, +) -> ClassResult: + """@brief Book a class, repeating it when the site drops the request. + + Before each repeat the class is read from the page again. When the button now shows the + state that the click should give, the click did go through although its confirmation + failed, so the class counts as newly booked or waitlisted and is not clicked again. + + @param gym The gym site. + @param card The class, as read from the page. + @param retries The most attempts to make. + @param sleep Waits between two attempts. + @return The class and what happened. + @exception RetriesExhaustedError Every attempt failed. + """ + attempts = 0 + + def attempt() -> ClassResult: + nonlocal attempts + attempts += 1 + if attempts == 1: + return book_class(gym, card) + current = _read_current_card(gym, card) + action = decide_outcome_action(card) + if CONFIRMED_STATE.get(action) is current.state: + outcome = OUTCOME_OF_ACTION[action] + logger.info(format_outcome(outcome, card)) + return ClassResult(card, outcome) + return book_class(gym, current) + + description = constants.RETRY_DESCRIPTION_BOOK.format(name=card.name, day=card.day_label) + return retry(attempt, retries, description, sleep=sleep) + + +def book_next_tuesday( + gym: GymPort, retries: int = constants.RETRY_COUNT, sleep: Sleep = time.sleep +) -> ClassResult | None: """@brief Book the next Tuesday class at the target time. @param gym The gym site. + @param retries The most attempts to book the class. + @param sleep Waits between two attempts. @return The result, or None when the schedule has no such class. - @exception GymError The page could not be read or the booking failed. + @exception GymError The page could not be read or the booking failed on every attempt. """ card = find_next_class(gym.list_class_cards(), constants.NEXT_CLASS_WEEKDAY) if card is None: @@ -57,11 +115,14 @@ def book_next_tuesday(gym: GymPort) -> ClassResult | None: ) ) return None - return book_class(gym, card) + return book_class_with_retry(gym, card, retries, sleep) def book_target_classes( - gym: GymPort, on_result: Callable[[ClassResult], None] | None = None + gym: GymPort, + on_result: Callable[[ClassResult], None] | None = None, + retries: int = constants.RETRY_COUNT, + sleep: Sleep = time.sleep, ) -> list[ClassResult]: """@brief Book every Tuesday and Thursday class at the target time, in page order. @@ -69,8 +130,10 @@ def book_target_classes( @param gym The gym site. @param on_result Called after each class was handled, for example to take a screenshot. + @param retries The most attempts to book one class. + @param sleep Waits between two attempts. @return What happened to each class; empty when the schedule has none. - @exception GymError The page could not be read or a booking failed. + @exception GymError The page could not be read or a booking failed on every attempt. """ cards = find_target_classes(gym.list_class_cards()) if not cards: @@ -78,7 +141,7 @@ def book_target_classes( return [] results: list[ClassResult] = [] for card in cards: - result = book_class(gym, card) + result = book_class_with_retry(gym, card, retries, sleep) results.append(result) if on_result is not None: on_result(result) diff --git a/src/snack_and_lift/application/retry.py b/src/snack_and_lift/application/retry.py new file mode 100644 index 0000000..48961fd --- /dev/null +++ b/src/snack_and_lift/application/retry.py @@ -0,0 +1,67 @@ +"""@file retry.py +@brief Lecture step 9: repeat a step that failed because the site dropped a request. +""" + +import logging +import time +from collections.abc import Callable + +from snack_and_lift import constants +from snack_and_lift.domain.errors import GymError, RetriesExhaustedError + +logger = logging.getLogger(__name__) + +## Waits for a number of seconds; tests pass a recorder so that they never really sleep. +Sleep = Callable[[float], None] + + +def retry[T]( + func: Callable[[], T], + retries: int = constants.RETRY_COUNT, + description: str | None = None, + *, + delay: float = constants.RETRY_DELAY_SECONDS, + sleep: Sleep = time.sleep, +) -> T: + """@brief Call a function until it succeeds, at most `retries` times. + + Only a `GymError` is repeated: it is what a dropped request looks like. Any other error, + such as missing credentials, is a defect and passes through at once. + + @param func The step, called without arguments. + @param retries The most attempts to make, at least 1. + @param description What the step does, for the log (never put a secret in it). + @param delay Seconds to wait between two attempts. + @param sleep Waits for a number of seconds. + @return What the function returned on its first successful attempt. + @exception ValueError `retries` is below 1. + @exception RetriesExhaustedError Every attempt raised a `GymError`; the last one is the cause. + """ + if retries < 1: + raise ValueError(constants.MESSAGE_RETRIES_INVALID.format(retries=retries)) + name = description or constants.RETRY_DEFAULT_DESCRIPTION + for attempt in range(1, retries + 1): + try: + result = func() + except GymError as error: + if attempt == retries: + message = constants.MESSAGE_RETRIES_EXHAUSTED.format( + description=name, retries=retries, error=error + ) + raise RetriesExhaustedError(message) from error + logger.warning( + constants.MESSAGE_RETRY_FAILED.format( + attempt=attempt, + retries=retries, + description=name, + error=str(error).rstrip("."), + ) + ) + sleep(delay) + else: + if attempt > 1: + logger.info( + constants.MESSAGE_RETRY_SUCCEEDED.format(description=name, attempts=attempt) + ) + return result + raise AssertionError("unreachable: the last attempt returns or raises") # pragma: no cover diff --git a/src/snack_and_lift/application/session.py b/src/snack_and_lift/application/session.py index 4066971..5aa3093 100644 --- a/src/snack_and_lift/application/session.py +++ b/src/snack_and_lift/application/session.py @@ -3,9 +3,12 @@ """ import logging +import time +from snack_and_lift import constants from snack_and_lift.application.credentials import Credentials from snack_and_lift.application.ports import GymPort +from snack_and_lift.application.retry import Sleep, retry logger = logging.getLogger(__name__) @@ -19,3 +22,25 @@ def login(gym: GymPort, credentials: Credentials) -> None: """ gym.log_in(credentials.email, credentials.password) logger.info("✓ Logged in: the class schedule is shown") + + +def login_with_retry( + gym: GymPort, + credentials: Credentials, + retries: int = constants.RETRY_COUNT, + sleep: Sleep = time.sleep, +) -> None: + """@brief Log in, repeating the login when the site drops the request. + + @param gym The gym site. + @param credentials The account to log in with. + @param retries The most attempts to make. + @param sleep Waits between two attempts. + @exception RetriesExhaustedError Every attempt failed. + """ + retry( + lambda: login(gym, credentials), + retries, + constants.RETRY_DESCRIPTION_LOGIN, + sleep=sleep, + ) diff --git a/src/snack_and_lift/application/verification.py b/src/snack_and_lift/application/verification.py index b2d83a1..adb6ca8 100644 --- a/src/snack_and_lift/application/verification.py +++ b/src/snack_and_lift/application/verification.py @@ -3,10 +3,12 @@ """ import logging +import time from collections.abc import Sequence from snack_and_lift import constants from snack_and_lift.application.ports import GymPort +from snack_and_lift.application.retry import Sleep, retry from snack_and_lift.domain.booking_entry import BookingEntry, is_target_entry from snack_and_lift.domain.errors import NoBookingsFoundError from snack_and_lift.domain.models import ClassResult @@ -30,18 +32,42 @@ def get_my_bookings(gym: GymPort) -> list[BookingEntry]: return entries -def verify_bookings(gym: GymPort, results: Sequence[ClassResult]) -> VerificationResult: +def get_my_bookings_with_retry( + gym: GymPort, retries: int = constants.RETRY_COUNT, sleep: Sleep = time.sleep +) -> list[BookingEntry]: + """@brief Read the My Bookings page, repeating it when the page fails to load. + + @param gym The gym site. + @param retries The most attempts to make. + @param sleep Waits between two attempts. + @return Every confirmed booking and waitlist entry. + @exception RetriesExhaustedError Every attempt failed. + """ + return retry( + lambda: get_my_bookings(gym), retries, constants.RETRY_DESCRIPTION_BOOKINGS, sleep=sleep + ) + + +def verify_bookings( + gym: GymPort, + results: Sequence[ClassResult], + retries: int = constants.RETRY_COUNT, + sleep: Sleep = time.sleep, +) -> VerificationResult: """@brief Compare what the bot booked with the My Bookings page. The page is not opened when the bot handled no class. @param gym The gym site. @param results What happened to each class. + @param retries The most attempts to read the page. + @param sleep Waits between two attempts. @return The comparison of the expected entries with the Tuesday and Thursday 6pm entries. - @exception NoBookingsFoundError Classes were handled but the page shows no entry. - @exception GymError The page could not be opened or read. + @exception RetriesExhaustedError The page could not be read on any attempt. """ if not results: return build_verification(results, []) - entries = [entry for entry in get_my_bookings(gym) if is_target_entry(entry)] + entries = [ + entry for entry in get_my_bookings_with_retry(gym, retries, sleep) if is_target_entry(entry) + ] return build_verification(results, entries) diff --git a/src/snack_and_lift/cli.py b/src/snack_and_lift/cli.py index ffbbb6f..dd53f5b 100644 --- a/src/snack_and_lift/cli.py +++ b/src/snack_and_lift/cli.py @@ -6,6 +6,7 @@ import argparse import logging import os import sys +import time from collections.abc import Callable, Sequence from pathlib import Path @@ -13,7 +14,8 @@ from snack_and_lift import constants from snack_and_lift.application.booking import book_next_tuesday, book_target_classes from snack_and_lift.application.credentials import Credentials, load_credentials from snack_and_lift.application.ports import GymPort -from snack_and_lift.application.session import login +from snack_and_lift.application.retry import Sleep +from snack_and_lift.application.session import login_with_retry from snack_and_lift.application.verification import verify_bookings from snack_and_lift.domain.errors import GymError, LoginFailedError, MissingCredentialsError from snack_and_lift.domain.models import ClassResult @@ -40,6 +42,22 @@ def selenium_gym_factory(headless: bool, profile_dir: Path, keep_open: bool) -> return SeleniumGym(create_driver(headless, profile_dir, keep_open)) +def _at_least_one(text: str) -> int: + """@brief Read a whole number of at least 1 for an option. + + @param text The text of the option. + @return The number. + @exception argparse.ArgumentTypeError The text is not a whole number of at least 1. + """ + try: + number = int(text) + except ValueError: + number = 0 + if number < 1: + raise argparse.ArgumentTypeError(f"{text!r} is not a whole number of at least 1") + return number + + def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: """@brief Read the command line. @@ -50,8 +68,8 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: prog="snack-and-lift", description="Books the Tuesday and Thursday 6pm classes at the Snack and Lift gym.", epilog=( - "exit codes: 0 done, 1 a step failed, 2 credentials missing, " - "3 the My Bookings page does not match the bookings made" + "exit codes: 0 done, 1 a step failed (also when every retry failed), " + "2 credentials missing, 3 the My Bookings page does not match the bookings made" ), ) parser.add_argument( @@ -82,6 +100,16 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: action="store_true", help="book only the next Tuesday 6pm class (lecture steps 3 and 4); skips the verification", ) + parser.add_argument( + "--retries", + type=_at_least_one, + default=constants.RETRY_COUNT, + metavar="N", + help=( + "attempts for the login, each booking and the My Bookings page when the site " + f"drops a request (default: {constants.RETRY_COUNT}, at least 1)" + ), + ) args = parser.parse_args(argv) if args.keep_open is None: args.keep_open = not args.headless @@ -121,7 +149,11 @@ def _save_console(gym: GymPort, screenshot_dir: Path | None, text: str, name: st def _run( - gym: GymPort, args: argparse.Namespace, recorder: ConsoleRecorder, credentials: Credentials + gym: GymPort, + args: argparse.Namespace, + recorder: ConsoleRecorder, + credentials: Credentials, + sleep: Sleep, ) -> bool: """@brief Open the site, log in, book the classes and verify them, saving the step images. @@ -133,15 +165,16 @@ def _run( @param args The parsed options. @param recorder The recorded console lines, drawn for step 4. @param credentials The account to log in with. + @param sleep Waits between two attempts of a retried step. @return False when the My Bookings page does not match the bookings made, else True. - @exception GymError A step failed. + @exception GymError A step failed on every attempt. """ gym.open_site() _save_step(gym, args.screenshot_dir, "step-1.png") - login(gym, credentials) + login_with_retry(gym, credentials, args.retries, sleep) _save_step(gym, args.screenshot_dir, "step-2.png") if args.first_tuesday_only: - book_next_tuesday(gym) + book_next_tuesday(gym, args.retries, sleep) _save_step(gym, args.screenshot_dir, constants.SCREENSHOT_STEP_3) _save_console(gym, args.screenshot_dir, recorder.text, constants.SCREENSHOT_STEP_4) return True @@ -153,7 +186,7 @@ def _run( _save_step(gym, args.screenshot_dir, constants.SCREENSHOT_STEP_3) window_saved = True - results = book_target_classes(gym, save_window_once) + results = book_target_classes(gym, save_window_once, args.retries, sleep) if not window_saved: _save_step(gym, args.screenshot_dir, constants.SCREENSHOT_STEP_3) _save_console(gym, args.screenshot_dir, recorder.text, constants.SCREENSHOT_STEP_4) @@ -163,22 +196,25 @@ def _run( detail = format_detail_list(results) logger.info("%s", detail) _save_console(gym, args.screenshot_dir, f"{summary}\n{detail}", constants.SCREENSHOT_STEP_6) - verification = verify_bookings(gym, results) + verification = verify_bookings(gym, results, args.retries, sleep) _save_step(gym, args.screenshot_dir, constants.SCREENSHOT_STEP_7) verification_text = format_verification(verification) logger.info("%s", verification_text) _save_console(gym, args.screenshot_dir, verification_text, constants.SCREENSHOT_STEP_8) + _save_console(gym, args.screenshot_dir, recorder.text, constants.SCREENSHOT_STEP_9) return verification.is_success def main( argv: Sequence[str] | None = None, gym_factory: GymFactory = selenium_gym_factory, + sleep: Sleep = time.sleep, ) -> int: - """@brief Start the bot: open the site, log in and book the next Tuesday 6pm class. + """@brief Start the bot: open the site, log in, book the classes and verify them. @param argv Arguments without the program name; None reads `sys.argv`. @param gym_factory Builds the gym site; tests pass a fake. + @param sleep Waits between two attempts of a retried step; tests pass a recorder. @return Process exit code: 0 done, 1 a step failed, 2 credentials missing, 3 mismatch. """ logging.basicConfig(level=logging.INFO, format="%(message)s") @@ -187,17 +223,20 @@ def main( recorder = ConsoleRecorder() root.addHandler(recorder) try: - return _main(argv, gym_factory, recorder) + return _main(argv, gym_factory, recorder, sleep) finally: root.removeHandler(recorder) -def _main(argv: Sequence[str] | None, gym_factory: GymFactory, recorder: ConsoleRecorder) -> int: +def _main( + argv: Sequence[str] | None, gym_factory: GymFactory, recorder: ConsoleRecorder, sleep: Sleep +) -> int: """@brief The body of `main`, run while the recorder is attached. @param argv Arguments without the program name. @param gym_factory Builds the gym site. @param recorder The console recorder. + @param sleep Waits between two attempts of a retried step. @return Process exit code. """ _use_utf8_output() @@ -209,7 +248,7 @@ def _main(argv: Sequence[str] | None, gym_factory: GymFactory, recorder: Console return 2 gym = gym_factory(args.headless, args.profile_dir, args.keep_open) try: - verified = _run(gym, args, recorder, credentials) + verified = _run(gym, args, recorder, credentials, sleep) except LoginFailedError as error: logger.error("Login failed: %s", error) return 1 diff --git a/src/snack_and_lift/constants.py b/src/snack_and_lift/constants.py index 98143f8..d6496f4 100644 --- a/src/snack_and_lift/constants.py +++ b/src/snack_and_lift/constants.py @@ -18,6 +18,31 @@ WAIT_TIMEOUT_SECONDS: Final[int] = 10 ## Attempts of the `retry` wrapper before it gives up (lecture step 9). RETRY_COUNT: Final[int] = 7 +## Seconds to wait between two attempts of the `retry` wrapper. +RETRY_DELAY_SECONDS: Final[float] = 0.5 + +## Name of a retried step that was given no description. +RETRY_DEFAULT_DESCRIPTION: Final[str] = "operation" + +## Descriptions of the retried steps, as they appear in the log. +RETRY_DESCRIPTION_LOGIN: Final[str] = "login" +RETRY_DESCRIPTION_BOOK: Final[str] = "book {name} on {day}" +RETRY_DESCRIPTION_BOOKINGS: Final[str] = "get my bookings" + +## Messages of the `retry` wrapper. +MESSAGE_RETRY_FAILED: Final[str] = ( + "⚠ Attempt {attempt}/{retries} failed: {description}: {error}. Retrying..." +) +MESSAGE_RETRY_SUCCEEDED: Final[str] = "✓ {description} succeeded after {attempts} attempts" +MESSAGE_RETRIES_EXHAUSTED: Final[str] = "{description} failed after {retries} attempts: {error}" +MESSAGE_RETRIES_INVALID: Final[str] = "retries must be at least 1, got {retries}" + +## Error raised when a class card is no longer on the schedule while a booking is repeated. +MESSAGE_CLASS_NOT_ON_SCHEDULE: Final[str] = "class {card_id} not found on the schedule" + +## File name of the screenshot of lecture step 9 (the console with the retry warnings). +SCREENSHOT_STEP_9: Final[str] = "step-9.png" + ## Weekdays of the classes the bot books, as the site abbreviates them. TARGET_WEEKDAYS: Final[tuple[str, ...]] = ("Tue", "Thu") diff --git a/src/snack_and_lift/domain/errors.py b/src/snack_and_lift/domain/errors.py index 4208839..6c43aab 100644 --- a/src/snack_and_lift/domain/errors.py +++ b/src/snack_and_lift/domain/errors.py @@ -23,6 +23,10 @@ class PageFormatError(GymError): """@brief A text read from the page (a day label or a time) has an unexpected format.""" +class RetriesExhaustedError(GymError): + """@brief A step failed on every attempt; the cause is the error of the last attempt.""" + + class NoBookingsFoundError(GymError): """@brief The My Bookings page shows no booking where the bot expects some. diff --git a/src/snack_and_lift/infrastructure/selenium_gym.py b/src/snack_and_lift/infrastructure/selenium_gym.py index d975aee..bbefea8 100644 --- a/src/snack_and_lift/infrastructure/selenium_gym.py +++ b/src/snack_and_lift/infrastructure/selenium_gym.py @@ -88,11 +88,18 @@ class SeleniumGym: @exception LoginFailedError The site shows a login error. @exception GymError The page did not behave as expected. """ - self._wait.until(ec.element_to_be_clickable((By.ID, constants.NAV_LOGIN_BUTTON_ID))).click() - self._wait.until( + # After a failed login the form is still shown, so a repeated login skips the nav button. + if not self._driver.find_elements(By.ID, constants.LOGIN_EMAIL_INPUT_ID): + self._wait.until( + ec.element_to_be_clickable((By.ID, constants.NAV_LOGIN_BUTTON_ID)) + ).click() + email_input = self._wait.until( ec.presence_of_element_located((By.ID, constants.LOGIN_EMAIL_INPUT_ID)) - ).send_keys(email) - self._driver.find_element(By.ID, constants.LOGIN_PASSWORD_INPUT_ID).send_keys(password) + ) + password_input = self._driver.find_element(By.ID, constants.LOGIN_PASSWORD_INPUT_ID) + for field, value in ((email_input, email), (password_input, password)): + field.clear() + field.send_keys(value) self._driver.find_element(By.ID, constants.LOGIN_SUBMIT_BUTTON_ID).click() self._wait.until( ec.any_of( diff --git a/tests/e2e/fixtures/login.html b/tests/e2e/fixtures/login.html index a32a137..573b5f7 100644 --- a/tests/e2e/fixtures/login.html +++ b/tests/e2e/fixtures/login.html @@ -12,8 +12,19 @@ // Dummy account of this fixture only; it is not a real account. const ACCEPTED_EMAIL = "fixture@example.invalid"; const ACCEPTED_PASSWORD = "fixture-password"; + // Imitates the network simulation: ?fail=N drops the first N submits after a short delay. + let failuresLeft = parseInt(new URLSearchParams(window.location.search).get("fail") || "0", 10); document.getElementById("login-form").addEventListener("submit", function (event) { event.preventDefault(); + document.getElementById("message-area").innerHTML = ""; + if (failuresLeft > 0) { + failuresLeft -= 1; + setTimeout(function () { + document.getElementById("message-area").innerHTML = + '
Network request failed. Please try again.
'; + }, 100); + return; + } const email = document.getElementById("email-input").value; const password = document.getElementById("password-input").value; if (email === ACCEPTED_EMAIL && password === ACCEPTED_PASSWORD) { diff --git a/tests/e2e/fixtures/my_bookings.html b/tests/e2e/fixtures/my_bookings.html index 1c80c86..34ad107 100644 --- a/tests/e2e/fixtures/my_bookings.html +++ b/tests/e2e/fixtures/my_bookings.html @@ -11,7 +11,13 @@