diff --git a/docs/DESIGN.md b/docs/DESIGN.md index a8cf49c..2e1c292 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -484,7 +484,11 @@ removes the lines the answer covered. A line that arrives while the turn runs is when it asks (`incoming`, section 6); the answer names the ones it covered, and the rest stay for the next turn. An answer that covers one message replies to it; one that covers several is sent plainly. On an exception, a timeout or `kept: false` the app posts `ai_down` with the keyboard, applies no -block, and leaves every line pending, handed-over ones too, so they go with the next turn. A `typing` +block, and leaves every line pending, handed-over ones too, so they go with the next turn. An +exception with a numeric `retry_after` (seconds) means the provider is out of capacity: the app posts +`ai_busy` with the wait and the keyboard, once per busy spell, keeps every line pending, and the +first tick after the wait (at least 60 s) runs the chat's turn again by itself; lines that arrive +meanwhile wait and go with it. A `typing` chat action is sent once at the start. **Restarts.** A redeploy can replace the container at any moment, and it loses no message. Telegram's @@ -742,6 +746,11 @@ The calendar plan is there only with `calendar.events` in the profile (5.6). escritos. Puedes anotarlo con los botones y, si me lo vuelves a escribir más tarde, lo leo.» plus the keyboard. No block is applied. Her message stays pending and goes with her next one. Nothing is retried in the background. +- The plugin is out of capacity (its error has `retry_after`): «Ahora no puedo contestar: no me queda + capacidad hasta dentro de unos 66 minutos. Entonces te contesto a todo, sin que haga falta repetir + nada. Mientras, puedes anotarlo con los botones.» plus the keyboard, once per busy spell (hours from + 90 minutes up). Her messages stay pending, the ones she sends meanwhile wait with them, and the + turn runs again by itself after the wait; a restart meanwhile answers them at once. ### 5.5 Settings from the chat @@ -919,6 +928,10 @@ Return value, either a bare string (no conversation kept) or: - `kept: false` means the provider did not store the turn. The app treats it as a failed turn: it posts `ai_down`, applies no block from `text`, and sends the same `new` lines again with the next turn. A plugin that has a usable answer returns `kept: true` with it. +- A provider out of capacity raises an exception with a numeric `retry_after` attribute (seconds + until it has capacity again). The app tells the chat the wait once and runs the turn again by + itself after it (5.2). A `RuntimeError` subclass keeps an older app working: it reads it as any + other failure. - The plugin must return before `settings.timeout_s`; the app cannot stop its thread. A plugin that outlives the wait would store a turn the app never saw. - The record and settings blocks come back inside `text`; the app extracts them. A provider that @@ -971,6 +984,9 @@ locale. English source strings and their Spanish translations: | `line_deleted` | • {date}: deleted | • {date}: borrado | | `invalid_block` | I could not note that properly, so I saved nothing. Can you tell me with the buttons? | No he sabido anotarlo bien, así que no he guardado nada. ¿Me lo dices con los botones? | | `ai_down` | Right now I cannot read written messages. You can note it with the buttons, and if you write it again later I will read it. | Ahora mismo no puedo leer mensajes escritos. Puedes anotarlo con los botones y, si me lo vuelves a escribir más tarde, lo leo. | +| `ai_busy` | I can't answer right now: I have no capacity left for {wait}. I'll answer everything then, with no need to repeat anything. Meanwhile you can note it with the buttons. | Ahora no puedo contestar: no me queda capacidad hasta dentro de {wait}. Entonces te contesto a todo, sin que haga falta repetir nada. Mientras, puedes anotarlo con los botones. | +| `busy_minutes` | about {n} minutes | unos {n} minutos | +| `busy_hours` | about {n} hours | unas {n} horas | | `no_ai` | Without the assistant I cannot read written messages. You can note it with the buttons: | Sin el asistente no entiendo mensajes escritos. Puedes anotarlo con los botones: | | `voice_echo` | 🎤 {text} | 🎤 {text} | | `voice_off` | Right now I cannot listen to voice notes. Can you write it to me? | Ahora mismo no puedo escuchar notas de voz. ¿Me lo escribes? | @@ -1170,7 +1186,7 @@ joins a group or its abilities change. | `test_records.py` | derive_starts reproduces the fictional log's flags; the gap rule at exactly 10 vs 11 days; `none` and `spotting` never start a cycle; replace-field semantics; delete; untouched lines byte-identical; validate_block refuses a future date, a date older than 90 days, pain 11, an unknown flow, an unknown symptom, an unknown key, 15 days, each with a reason code that does not contain the value; a Change with before == after is empty | | `test_cycle.py` | `period_max_day` moves the last daily ask and the catch-up day; lengths outside 20..45 dropped; median and window; fewer than 3 lengths; period length; `bot_day` at 04:59 vs 05:01; DST days 2026-03-29 and 2026-10-25; each phase kind with its exit; day 8 is `period` only while days are missing | | `test_schedule.py` | quiet before the window; the ask at `ask_at`, not before, not twice, not after `ask_at + 2 h`; `ask_at` 23:00 with ticks at 22:59, 23:00, 00:30 and 01:00 the next day (no, yes, yes, no); skipped when today has a flow; a restart on the same day asks no second time; late asks daily; a 60-day frozen run counts the messages (at most one scheduled per bot day); missing days older than 3 never asked; the day-8 catch-up card once, with the catch-up wording, and nothing on day 8 when nothing is missing | -| `test_bot.py` | the three-tap flow edits one message; a start from the card asks about the day before, a toggle there writes a symptom-only day and leaves the start alone, and the step is skipped when that day already says something or the day is not a start; the eve toggles follow what she logs there most; `/resumen` tells the day-before pattern from three starts on; `period_max_day` from the profile moves the last daily ask; a `settings` block rewrites the profile by line, commits, pushes and takes effect, a refused one (each reason, and broken JSON) changes nothing; `/hora` needs no assistant and the next tick asks at the new hour; a profile pulled from the remote is reloaded and a bad one refused; `set_profile_lines` keeps every other byte; `/estado` shows the ask hour; a stale keyboard works by date; a callback dated 91 days back gets the toast and writes nothing; «Igual que ayer»; the picker; «Ya no sangro» closes and posts the closing line; a close undone and redone posts it again; day 7 line; pain 9 sends `pain_high` once, 9 again does not, 3 then 9 does; «Contarte más» sends selective force_reply with the mention; a reply triggers a turn with the turn shape of section 6; a turn names its chat and the sender of its oldest line, an admin's when that line is the admin's; a reply to someone else's photo, file, video, voice note or text names its kind, sender and UTC date on a text, caption, voice or `[not to you]` line, a reply to the bot or to a topic's first message does not; a message mentioning the bot does too; the record is written and the confirmation is built from the Change; a block whose days are unchanged posts no «Anotado:»; a future-dated block writes nothing; plugin raises, the keyboard comes and the line stays pending; `kept: false` posts `ai_down`, writes nothing, and the resend yields exactly one answer; 20 queued lines are capped to 10 and the next turn succeeds; `/nuevo` clears pending lines; a message arriving mid-turn is not dropped; an answer ending in "?" carries force_reply; plain group text from her ignored with privacy on, answered with privacy off, an admin's plain text ignored in both; a stranger's tap gets the toast; `/anotar ayer mucho 6 calambres`; `/estado` admin-only and every phase kind renders through a locale key; migration; a voice note with no mention is heard, echoed under the note and sent as `[voice note]`; an audio file and an admin's note in private; strangers' and other chats' notes ignored; `voice_off` (no plugin, no `transcribe`, `NotImplementedError`), `voice_failed` (plugin, empty text, download, `getFile`) and `voice_too_long` (on arrival and from `getFile`); a heard note is never heard twice; the echo is escaped and cut; a photo with a mention in its caption reaches the turn as `[photo: inbox/-foto.jpg]` with the largest size's bytes in `attachments`, and no caption word is logged; an admin's PDF in private is named by its folded file name; a spreadsheet or any other kind of document reaches the turn as an attachment with its own extension; a video, a GIF, a video note and each kind of sticker reach it under their own stem and extension; one over 20 MiB `file_too_big`, a failed download `file_failed` with the caption still answered; strangers' and other chats' photos are ignored; without an assistant a photo gets `no_ai`; photos past the turn budget go with the next turn on their own; a bot that is a group admin reads her plain text; `later` posts after the answer, fails with `later_failed`, says nothing when empty, runs one job at a time in order without holding turns, and never runs for a turn not kept | +| `test_bot.py` | the three-tap flow edits one message; a start from the card asks about the day before, a toggle there writes a symptom-only day and leaves the start alone, and the step is skipped when that day already says something or the day is not a start; the eve toggles follow what she logs there most; `/resumen` tells the day-before pattern from three starts on; `period_max_day` from the profile moves the last daily ask; a `settings` block rewrites the profile by line, commits, pushes and takes effect, a refused one (each reason, and broken JSON) changes nothing; `/hora` needs no assistant and the next tick asks at the new hour; a profile pulled from the remote is reloaded and a bad one refused; `set_profile_lines` keeps every other byte; `/estado` shows the ask hour; a stale keyboard works by date; a callback dated 91 days back gets the toast and writes nothing; «Igual que ayer»; the picker; «Ya no sangro» closes and posts the closing line; a close undone and redone posts it again; day 7 line; pain 9 sends `pain_high` once, 9 again does not, 3 then 9 does; «Contarte más» sends selective force_reply with the mention; a reply triggers a turn with the turn shape of section 6; a turn names its chat and the sender of its oldest line, an admin's when that line is the admin's; a reply to someone else's photo, file, video, voice note or text names its kind, sender and UTC date on a text, caption, voice or `[not to you]` line, a reply to the bot or to a topic's first message does not; a message mentioning the bot does too; the record is written and the confirmation is built from the Change; a block whose days are unchanged posts no «Anotado:»; a future-dated block writes nothing; plugin raises, the keyboard comes and the line stays pending; a busy plugin (`retry_after`) is told once in minutes or hours, holds every line through later messages and a restart, and the tick after the wait answers them all with nobody writing again; `kept: false` posts `ai_down`, writes nothing, and the resend yields exactly one answer; 20 queued lines are capped to 10 and the next turn succeeds; `/nuevo` clears pending lines; a message arriving mid-turn is not dropped; an answer ending in "?" carries force_reply; plain group text from her ignored with privacy on, answered with privacy off, an admin's plain text ignored in both; a stranger's tap gets the toast; `/anotar ayer mucho 6 calambres`; `/estado` admin-only and every phase kind renders through a locale key; migration; a voice note with no mention is heard, echoed under the note and sent as `[voice note]`; an audio file and an admin's note in private; strangers' and other chats' notes ignored; `voice_off` (no plugin, no `transcribe`, `NotImplementedError`), `voice_failed` (plugin, empty text, download, `getFile`) and `voice_too_long` (on arrival and from `getFile`); a heard note is never heard twice; the echo is escaped and cut; a photo with a mention in its caption reaches the turn as `[photo: inbox/-foto.jpg]` with the largest size's bytes in `attachments`, and no caption word is logged; an admin's PDF in private is named by its folded file name; a spreadsheet or any other kind of document reaches the turn as an attachment with its own extension; a video, a GIF, a video note and each kind of sticker reach it under their own stem and extension; one over 20 MiB `file_too_big`, a failed download `file_failed` with the caption still answered; strangers' and other chats' photos are ignored; without an assistant a photo gets `no_ai`; photos past the turn budget go with the next turn on their own; a bot that is a group admin reads her plain text; `later` posts after the answer, fails with `later_failed`, says nothing when empty, runs one job at a time in order without holding turns, and never runs for a turn not kept | | `test_repo.py` | one commit after 60 s quiet for three taps; flush on SIGTERM; the human clone pushes a profile edit between the bot's commit and push, the bot rebases and both land; the human clone changes a line of `log.jsonl` the bot also changed: the pull completes, the bot's line wins, the human's other edits survive, the push lands; the remote refuses the push (a hook that exits 1): the commit stays, `/estado` counts it, the next flush pushes after the hook is removed (positive control); `/deshacer` on a dirty file restores HEAD with no commit, on a clean `log:` HEAD makes an `undo:` commit, and on an `undo:` HEAD says nothing to undo; a pull that changes the profile raises the reload flag and one that changes only the log does not; `write_profile` commits the profile alone (a dirty log stays out), `flush` pushes it, and a refused or failed commit leaves the file and the index as they were (a pre-commit hook that exits 1, then removed as the positive control); commit messages contain no flow or symptom words; the token never appears in `git config` | | `test_applehealth.py` | a fictional export in a zip with a localised member name and a `_cda.xml` beside it; the three value forms; metadata child counted; `NotPresent` skipped; heaviest of a day; `IntermenstrualBleeding` to `spotting`; a `startDate` at 23:30 +0000 lands on the next day in the profile timezone; chat days never overwritten; a rerun is a no-op; `--dry-run` writes nothing | | `test_turns.py` | section 5.2: one answer per burst, lines handed to a running turn, edits, choices as buttons, asks; a line queued before a restart and a turn killed mid-flight are answered by the next process | diff --git a/supercycletracker/__init__.py b/supercycletracker/__init__.py index e9a5452..b88e970 100644 --- a/supercycletracker/__init__.py +++ b/supercycletracker/__init__.py @@ -1,3 +1,3 @@ """SuperCycleTracker: a menstrual cycle tracker that lives in a Telegram group.""" -__version__ = "0.4.10" +__version__ = "0.5.0" diff --git a/supercycletracker/bot.py b/supercycletracker/bot.py index 16142b6..95365bf 100644 --- a/supercycletracker/bot.py +++ b/supercycletracker/bot.py @@ -28,6 +28,7 @@ import html import json import logging +import math import re import secrets import threading @@ -109,6 +110,9 @@ MENU = ("hoy", "resumen", "anotar", "hora", "deshacer", "recordatorios", "ayuda") # the menu DEBOUNCE_S = 2.5 # a chat's turn starts this long after its newest message: a burst goes together VOICE_WAIT_S = 30 # and waits this long at most, from a note's arrival, for a note being heard +# A plugin out of capacity raises an error with `retry_after` (seconds): the chat's lines wait that +# long, at least BUSY_MIN_S, and the next tick after it runs their turn, with nobody writing again. +BUSY_MIN_S = 60 SAID_MAX = 30 # answered messages kept per chat, so an edit of one can be compared with it ANSWER_CLIP = 1500 # characters of the earlier answer that an edit of an answered message carries OPTIONS_TTL_S = 12 * 3600 # a choice under an answer can be tapped this long @@ -440,6 +444,16 @@ def has(self, line_id: object) -> bool: return line_id in self.ids or line_id in self.pulled +def _retry_after(e: BaseException) -> float | None: + """The seconds a busy plugin asks to wait (its error's `retry_after`), or None.""" + later = getattr(e, "retry_after", None) + if isinstance(later, bool) or not isinstance(later, (int, float)): + return None + if not math.isfinite(later) or later <= 0: + return None # NaN or infinity would never end the wait + return float(later) + + def _mid(line: Mapping) -> int | None: """The Telegram message a pending line answers to (lines from 0.2 carry only `id`).""" if "mid" in line: @@ -589,6 +603,8 @@ def __init__( self._newest: dict[int, float] = {} self._voicing: dict[int, dict] = {} # chat -> {line id: when the note arrived} self._running: dict[int, Turn] = {} + self._resting: dict[int, float] = {} # chat -> clock() its turn waits for (busy plugin) + self._told_busy: set[int] = set() # chats told of the wait, until an answer goes out self.asks: dict[str, dict] = {} # the plugin's open asks, by the key on their buttons self.hearer = Worker(self._hear_chat, name="sct-hear") self.side = Worker(lambda job: job(), name="sct-asks") # answers to asks, off the poll loop @@ -1722,6 +1738,7 @@ def tick(self, now: datetime | None = None) -> None: except Exception as e: # noqa: BLE001 log.warning("ask expiry failed: %s", safe(e)) for step, what in ( + (self.wake, "busy wake"), (self.tick_reminders, "reminder tick"), (self.expire_offers, "offer expiry"), (self.expire_options, "options expiry"), @@ -1850,8 +1867,12 @@ def _turn(self, chat: int) -> None: what arrives while it runs is handed over through `incoming`. On success the text is posted, the block applied and the lines it covered removed (the rest stay for the next turn). On an exception, a timeout or `kept: false`: «ai_down» with the keyboard, no - block, and every line stays for the next turn.""" + block, and every line stays for the next turn; on an exception with `retry_after`, + «ai_busy» once and the turn runs again by itself after the wait (`rest`).""" key = f"pending:{chat}" + with self._q: + if self._resting.get(chat, 0) > self.clock(): + return # the plugin is busy: `wake` queues the chat again when the wait is over self.settle(chat) with self._q: lines = [x for x in self.kv.get_json(key, []) or [] if "content" in x] @@ -1932,6 +1953,10 @@ def _converse(self, chat: int, key: str, lines: list[dict], state: Turn) -> None ) except Exception as e: # noqa: BLE001 - the plugin may raise anything log.warning("ai turn failed: %s", safe(e)) + later = _retry_after(e) + if later is not None: # out of capacity: the lines stay and go again by themselves + self.rest(chat, later) + return self.post(chat, self.t("ai_down"), self.today_keyboard()) return finally: @@ -1969,6 +1994,8 @@ def _converse(self, chat: int, key: str, lines: list[dict], state: Turn) -> None # she never saw the answer: a failed turn, so the buttons and the lines stay self.post(chat, self.t("ai_down"), self.today_keyboard()) return + with self._q: + self._told_busy.discard(chat) # answered: a later busy spell is told again if text: self.kv.add_chat(chat, "assistant", None, text, int(self.now().timestamp())) if block is not None: @@ -1994,6 +2021,37 @@ def _converse(self, chat: int, key: str, lines: list[dict], state: Turn) -> None self.later.submit(lambda: self.run_later(chat, job)) log.info("later queued") + def rest(self, chat: int, seconds: float) -> None: + """The plugin is out of capacity for `seconds`: say so once, with the wait and the keyboard, + and run the chat's turn by itself when it is over (`wake`). Its lines stay in + `pending:`, so a restart meanwhile still answers them; what arrives meanwhile waits + with them and joins that turn.""" + seconds = max(float(seconds), BUSY_MIN_S) + with self._q: + self._resting[chat] = self.clock() + seconds + told = chat in self._told_busy + self._told_busy.add(chat) + log.info("ai busy, retry in %ds", round(seconds)) + if told: + return + minutes = max(1, round(seconds / 60)) + wait = ( + self.t("busy_minutes", n=minutes) + if minutes < 90 + else self.t("busy_hours", n=round(minutes / 60)) + ) + self.post(chat, self.t("ai_busy", wait=wait), self.today_keyboard()) + + def wake(self) -> None: + """Queue the turn of every chat whose wait for a busy plugin is over.""" + now = self.clock() + with self._q: + due = [chat for chat, at in self._resting.items() if at <= now] + for chat in due: + del self._resting[chat] + for chat in due: + self.worker.submit(chat) + def fetch_files(self, lines: list[dict], chat: int) -> tuple[list[dict], list[dict]]: """Download the photos and files of the turn's lines, 20 MiB each and 25 MiB in all, as the plugin's attachments `{"name", "mime", "data", "from"}`. A line past the budget diff --git a/supercycletracker/locales/en.yaml b/supercycletracker/locales/en.yaml index d897f28..fff7372 100644 --- a/supercycletracker/locales/en.yaml +++ b/supercycletracker/locales/en.yaml @@ -34,6 +34,9 @@ line_deleted: "• {date}: deleted" line_note: "• {date}: note updated" invalid_block: "I could not note that properly, so I saved nothing. Can you tell me with the buttons?" ai_down: "Right now I cannot read written messages. You can note it with the buttons, and if you write it again later I will read it." +ai_busy: "I can't answer right now: I have no capacity left for {wait}. I'll answer everything then, with no need to repeat anything. Meanwhile you can note it with the buttons." +busy_minutes: "about {n} minutes" +busy_hours: "about {n} hours" no_ai: "Without the assistant I cannot read written messages. You can note it with the buttons:" voice_echo: "🎤 {text}" voice_off: "Right now I cannot listen to voice notes. Can you write it to me?" diff --git a/supercycletracker/locales/es.yaml b/supercycletracker/locales/es.yaml index 456c9bc..1c6ec09 100644 --- a/supercycletracker/locales/es.yaml +++ b/supercycletracker/locales/es.yaml @@ -32,6 +32,9 @@ line_deleted: "• {date}: borrado" line_note: "• {date}: nota actualizada" invalid_block: "No he sabido anotarlo bien, así que no he guardado nada. ¿Me lo dices con los botones?" ai_down: "Ahora mismo no puedo leer mensajes escritos. Puedes anotarlo con los botones y, si me lo vuelves a escribir más tarde, lo leo." +ai_busy: "Ahora no puedo contestar: no me queda capacidad hasta dentro de {wait}. Entonces te contesto a todo, sin que haga falta repetir nada. Mientras, puedes anotarlo con los botones." +busy_minutes: "unos {n} minutos" +busy_hours: "unas {n} horas" no_ai: "Sin el asistente no entiendo mensajes escritos. Puedes anotarlo con los botones:" voice_echo: "🎤 {text}" voice_off: "Ahora mismo no puedo escuchar notas de voz. ¿Me lo escribes?" diff --git a/tests/test_turns.py b/tests/test_turns.py index 19f8145..5c3cde4 100644 --- a/tests/test_turns.py +++ b/tests/test_turns.py @@ -597,3 +597,89 @@ def dies(*a, **kw): assert new.kv.get_json(f"pending:{GROUP}") is None finally: new.close() + + +# --- a plugin out of capacity ---------------------------------------------------------------- + + +def busy(seconds): + """What a provider with no capacity left raises: an error saying when to try again.""" + e = RuntimeError("no capacity") + e.retry_after = seconds + return e + + +def busy_texts(bot): + return [x for x in bot.tg.texts() if x.startswith("Ahora no puedo contestar")] + + +def test_a_busy_plugin_is_told_once_and_everything_is_answered_after_the_wait(make_bot): + bot = make_bot() + now = [1000.0] + bot.clock = lambda: now[0] + mod = plugin(bot) + mod.REPLIES[:] = [busy(3960)] + bot.handle({"message": message("me ha bajado")}) + bot.join_idle() + told = sent(bot)[-1] + assert told["text"] == bot.t("ai_busy", wait=bot.t("busy_minutes", n=66)) + assert buttons(told["reply_markup"])[0] == "f:20270304:light" # she can still use the buttons + bot.handle({"message": message("y me duele")}) + bot.join_idle() + assert len(mod.CALLS) == 1 # no call while the plugin is busy + assert len(bot.kv.get_json(f"pending:{GROUP}")) == 2 # both wait, on disk + bot.tick() + bot.join_idle() + assert len(mod.CALLS) == 1 # the wait is not over yet + now[0] += 3960 + bot.tick() # nobody writes again + bot.join_idle() + assert len(mod.CALLS) == 2 + assert [c.rsplit("] ", 1)[1] for c in contents(mod.CALLS[1])] == ["me ha bajado", "y me duele"] + assert "Vale." in bot.tg.texts() + assert len(busy_texts(bot)) == 1 + assert bot.kv.get_json(f"pending:{GROUP}") is None + + +def test_a_long_busy_spell_says_hours_and_a_longer_wait_is_not_told_again(make_bot): + bot = make_bot() + now = [1000.0] + bot.clock = lambda: now[0] + mod = plugin(bot) + mod.REPLIES[:] = [busy(3 * 3600), busy(600)] + bot.handle({"message": message("¿me toca ya?")}) + bot.join_idle() + assert bot.tg.texts()[-1] == bot.t("ai_busy", wait=bot.t("busy_hours", n=3)) + now[0] += 3 * 3600 + bot.wake() + bot.join_idle() + assert len(mod.CALLS) == 2 and len(busy_texts(bot)) == 1 # still busy: not told twice + now[0] += 600 + bot.wake() + bot.join_idle() + assert len(mod.CALLS) == 3 and bot.tg.texts()[-1] == "Vale." + assert bot.kv.get_json(f"pending:{GROUP}") is None + + +def test_lines_waiting_for_a_busy_plugin_are_answered_after_a_restart(make_bot, data_repo): + bot = make_bot() + plugin(bot).REPLIES[:] = [busy(3960)] + bot.handle({"message": message("¿me toca ya?")}) + bot.join_idle() + assert len(busy_texts(bot)) == 1 + new = _restart(bot, data_repo) + try: + calls = plugin(new).CALLS + assert [c.rsplit("] ", 1)[1] for c in contents(calls[-1])] == ["¿me toca ya?"] + assert new.tg.texts() == ["Vale."] + assert new.kv.get_json(f"pending:{GROUP}") is None + finally: + new.close() + + +@pytest.mark.parametrize("value", [None, True, 0, -5, "60", float("nan"), float("inf")]) +def test_only_a_finite_positive_retry_after_is_a_busy_spell(value): + from supercycletracker.bot import _retry_after + + assert _retry_after(busy(value)) is None + assert _retry_after(busy(30)) == 30.0