Enabled all Ruff lint rules and resolved findings.
CI / Formatting (push) Successful in 27s
CI / Linting (push) Successful in 9s
CI / Tests (push) Successful in 34s
CI / Type Checking (push) Successful in 7s
CI / Spelling (push) Successful in 7s

This commit is contained in:
2026-05-18 11:10:46 -04:00
parent 48a4fb9bbf
commit 795184cd17
11 changed files with 211 additions and 123 deletions
+11 -7
View File
@@ -31,6 +31,9 @@ if TYPE_CHECKING:
_MARKDOWN_ESCAPE_TABLE = str.maketrans({c: f"\\{c}" for c in r"\*_[]()~`#+-=|{}.!<>&"})
_SECONDS_PER_MINUTE = 60
_SECONDS_PER_HOUR = 60 * _SECONDS_PER_MINUTE
_SECONDS_PER_DAY = 24 * _SECONDS_PER_HOUR
def _sanitize_for_plain_text(text: str) -> str:
@@ -67,18 +70,19 @@ def _format_duration(timestamp_str: str, now: datetime) -> str:
seconds = int(delta.total_seconds())
if seconds < 0:
return "unknown duration"
if seconds < 60:
if seconds < _SECONDS_PER_MINUTE:
return f"{seconds} second{'s' if seconds != 1 else ''}"
if seconds < 3600:
minutes = seconds // 60
if seconds < _SECONDS_PER_HOUR:
minutes = seconds // _SECONDS_PER_MINUTE
return f"{minutes} minute{'s' if minutes != 1 else ''}"
if seconds < 86400:
hours = seconds // 3600
if seconds < _SECONDS_PER_DAY:
hours = seconds // _SECONDS_PER_HOUR
return f"{hours} hour{'s' if hours != 1 else ''}"
days = seconds // 86400
return f"{days} day{'s' if days != 1 else ''}"
except TypeError, ValueError:
return "unknown duration"
else:
days = seconds // _SECONDS_PER_DAY
return f"{days} day{'s' if days != 1 else ''}"
class CommandHandler:
+3 -2
View File
@@ -66,7 +66,8 @@ class _ResponseTimer:
"""Mark the request as successful so the duration is recorded."""
self._should_observe = True
def _finalize(self) -> None:
def finalize(self) -> None:
"""Record or discard the timing observation when the block exits."""
if self._should_observe:
self._gauge.labels(domain=self._domain).set(
max(time.monotonic() - self._start, 0)
@@ -196,7 +197,7 @@ class MetricsService:
try:
yield timer
finally:
timer._finalize()
timer.finalize()
def set_build_info(self, version: str) -> None:
"""Set the build version info metric.
+4 -1
View File
@@ -101,7 +101,9 @@ class NotificationService:
return
# Build the notification message
body_text = self._format_message(name, title, domain, tags, title_change)
body_text = self._format_message(
name, title, domain, tags, title_change=title_change
)
# Send notifications to all subscribed rooms in parallel
successful, failed = await self._broadcast_to_rooms(domain, body_text)
@@ -223,6 +225,7 @@ class NotificationService:
title: str,
domain: str,
tags: Sequence[str],
*,
title_change: bool,
) -> str:
"""Format the notification message body.
+9 -4
View File
@@ -15,6 +15,7 @@
"""HTTP client for querying Owncast instance APIs."""
import json
from http import HTTPStatus
from typing import TYPE_CHECKING, Any
import aiohttp
@@ -118,7 +119,7 @@ class OwncastClient:
url = f"https://{domain}{path}"
try:
async with self.session.get(url, allow_redirects=False) as response:
if response.status != 200:
if response.status != HTTPStatus.OK:
self.log.warning(
"[%s] Response to request on %s was not 200, got %s instead.",
domain,
@@ -147,7 +148,6 @@ class OwncastClient:
path,
)
return None
return result
except ValueError as e:
self.log.warning(
"[%s] Rejecting response to request on %s as could not "
@@ -157,6 +157,8 @@ class OwncastClient:
e,
)
return None
else:
return result
except (aiohttp.ClientError, TimeoutError, OSError) as e:
self.log.warning("[%s] Error making GET request to %s: %s", domain, path, e)
return None
@@ -237,8 +239,11 @@ class OwncastClient:
connector = self.session.connector
if connector is None:
return 0
idle = sum(len(conns) for conns in connector._conns.values())
active = len(connector._acquired)
# aiohttp does not expose public live pool counts; this metric intentionally
# mirrors its internal idle and active connection pools.
idle = sum(len(conns) for conns in connector._conns.values()) # noqa: SLF001
active = len(connector._acquired) # noqa: SLF001
return idle + active
async def close(self) -> None:
+16 -8
View File
@@ -33,19 +33,27 @@ _TEMPORARY_OFFLINE_NOTIFICATION_COOLDOWN = 7 * 60
_CLEANUP_WARNING_THRESHOLD = 83 * 24 * 60
_CLEANUP_DELETE_THRESHOLD = 90 * 24 * 60
_QUERY_EVERY_MINUTE_FAILURE_LIMIT = 4
_QUERY_EVERY_2_MINUTES_FAILURE_LIMIT = 9
_QUERY_EVERY_3_MINUTES_FAILURE_LIMIT = 14
_QUERY_EVERY_5_MINUTES_FAILURE_LIMIT = 29
_QUERY_EVERY_2_MINUTES_INTERVAL = 2
_QUERY_EVERY_3_MINUTES_INTERVAL = 3
_QUERY_EVERY_5_MINUTES_INTERVAL = 5
_QUERY_EVERY_15_MINUTES_INTERVAL = 15
def _should_query_stream(failure_counter: int) -> bool:
"""Determine if a stream should be queried based on failure count."""
if failure_counter <= 4:
if failure_counter <= _QUERY_EVERY_MINUTE_FAILURE_LIMIT:
return True
if failure_counter <= 9:
return failure_counter % 2 == 0
if failure_counter <= 14:
return failure_counter % 3 == 0
if failure_counter <= 29:
return failure_counter % 5 == 0
return failure_counter % 15 == 0
if failure_counter <= _QUERY_EVERY_2_MINUTES_FAILURE_LIMIT:
return failure_counter % _QUERY_EVERY_2_MINUTES_INTERVAL == 0
if failure_counter <= _QUERY_EVERY_3_MINUTES_FAILURE_LIMIT:
return failure_counter % _QUERY_EVERY_3_MINUTES_INTERVAL == 0
if failure_counter <= _QUERY_EVERY_5_MINUTES_FAILURE_LIMIT:
return failure_counter % _QUERY_EVERY_5_MINUTES_INTERVAL == 0
return failure_counter % _QUERY_EVERY_15_MINUTES_INTERVAL == 0
class StreamMonitor:
+25 -50
View File
@@ -42,68 +42,43 @@ python_version = "3.14"
strict = true
warn_unreachable = true
explicit_package_bases = true
exclude = ["owncastsentry/_version\\.py", "tests/"]
# Maubot's pytest helpers are not fully typed, so strict mypy checks on
# integration tests produce noise around fixture-provided bot/plugin objects.
exclude = ["tests/"]
[tool.ruff]
target-version = "py314"
extend-exclude = ["owncastsentry/_version.py"] # auto-generated by hatch-vcs
[tool.ruff.lint]
select = [
# Core
"F", # Pyflakes
"E", # pycodestyle errors
"W", # pycodestyle warnings
"N", # pep8-naming
"D", # pydocstyle
"I", # isort
"ICN", # flake8-import-conventions
# Correctness & bugs
"B", # flake8-bugbear
"ASYNC", # flake8-async
"DTZ", # flake8-datetimez
"RSE", # flake8-raise
"RET", # flake8-return
"A", # flake8-builtins
"PIE", # flake8-pie
# Modernization & simplification
"UP", # pyupgrade
"SIM", # flake8-simplify
"C4", # flake8-comprehensions
"FLY", # flynt (f-string conversion)
"PTH", # flake8-use-pathlib
# Performance
"PERF", # Perflint
# Security
"S", # flake8-bandit
# Code hygiene
"T10", # flake8-debugger
"T20", # flake8-print
"ERA", # eradicate
"PGH", # pygrep-hooks
"TC", # flake8-type-checking
# Testing
"PT", # flake8-pytest-style
# Ruff-specific
"RUF", # Ruff-specific rules
]
select = ["ALL"]
ignore = [
"D203", # incompatible with D211 (no blank line before class docstring)
"D213", # incompatible with D212 (summary on first line)
"ANN401", # Any is valid at system boundaries; mypy strict handles real issues
"C901", # McCabe complexity: noisy and not actionable
"COM812", # handled by the formatter
"D203", # incompatible with D211 (no blank line before class docstring)
"D213", # incompatible with D212 (summary on first line)
"EM", # exception message style: inline literals are fine
"PLR0911", # too many return statements: flat early-returns are clear
"PLR0912", # too many branches: inherent in parsers, validators, CLI
"PLR0913", # too many arguments: API surfaces and constructors need them
"PLR0915", # too many statements: inherent in parsers, validators, CLI
"TRY003", # inline exception messages are fine (complements EM ignore)
"TRY301", # raise inside try: guard clauses don't need helper functions
]
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["S101"]
"tests/**" = [
"S101", # assert is standard for pytest
"S311", # pseudo-random generators are fine in tests
"SLF001", # tests legitimately access private members for verification
"ARG001", # unused args are normal for fixtures and handler stubs
"PLR2004", # magic values are clear in test assertions
]
[tool.coverage.run]
source = ["owncastsentry"]
@@ -114,4 +89,4 @@ show_missing = true
skip_empty = true
[tool.codespell]
skip = "uv.lock"
skip = "owncastsentry/_version.py,uv.lock"
+8 -4
View File
@@ -33,6 +33,10 @@ if TYPE_CHECKING:
from collections.abc import AsyncIterator
from pathlib import Path
from maubot import Plugin
from mautrix.util.async_db import UpgradeTable
from mautrix.util.config import BaseProxyConfig
from owncastsentry.metrics import MetricsService
from owncastsentry.types import StreamConfig, StreamState
@@ -72,19 +76,19 @@ def subscription_repo(database: Database) -> SubscriptionRepository:
@pytest.fixture
def maubot_plugin_class():
def maubot_plugin_class() -> type[Plugin]:
"""Use OwncastSentry as the plugin class for maubot integration tests."""
return OwncastSentry
@pytest.fixture
def maubot_plugin_config_class():
def maubot_plugin_config_class() -> type[BaseProxyConfig]:
"""Use our Config class for maubot integration tests."""
return Config
@pytest.fixture
def maubot_upgrade_table():
def maubot_upgrade_table() -> UpgradeTable:
"""Provide the database upgrade table for maubot integration tests."""
return get_upgrade_table()
@@ -220,7 +224,7 @@ class _StubOwncastClient:
self.queried_domains.append(domain)
return self.stream_state
async def get_stream_config(self, domain: str) -> StreamConfig | None:
async def get_stream_config(self, _domain: str) -> StreamConfig | None:
"""Return the configured stream config."""
self.config_call_count += 1
return self.stream_config
+46 -29
View File
@@ -16,12 +16,16 @@
import json
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING
import pytest
import time_machine
from aioresponses import aioresponses
from owncastsentry.commands import (
_SECONDS_PER_DAY,
_SECONDS_PER_HOUR,
_SECONDS_PER_MINUTE,
_escape_markdown,
_format_duration,
_sanitize_for_markdown,
@@ -30,6 +34,11 @@ from owncastsentry.owncast_client import _OWNCAST_STATUS_PATH
from owncastsentry.types import UNKNOWN_STATUS_THRESHOLD, StreamState
from tests.conftest import VALID_STATUS_RESPONSE
if TYPE_CHECKING:
from maubot.testing.bot import TestBot
from owncastsentry import OwncastSentry
class TestEscapeMarkdown:
"""Markdown special character escaping."""
@@ -78,12 +87,12 @@ class TestFormatDuration:
[
pytest.param(1, "1 second", id="singular-second"),
pytest.param(30, "30 seconds", id="plural-seconds"),
pytest.param(60, "1 minute", id="singular-minute"),
pytest.param(120, "2 minutes", id="plural-minutes"),
pytest.param(3600, "1 hour", id="singular-hour"),
pytest.param(7200, "2 hours", id="plural-hours"),
pytest.param(86400, "1 day", id="singular-day"),
pytest.param(172800, "2 days", id="plural-days"),
pytest.param(_SECONDS_PER_MINUTE, "1 minute", id="singular-minute"),
pytest.param(2 * _SECONDS_PER_MINUTE, "2 minutes", id="plural-minutes"),
pytest.param(_SECONDS_PER_HOUR, "1 hour", id="singular-hour"),
pytest.param(2 * _SECONDS_PER_HOUR, "2 hours", id="plural-hours"),
pytest.param(_SECONDS_PER_DAY, "1 day", id="singular-day"),
pytest.param(2 * _SECONDS_PER_DAY, "2 days", id="plural-days"),
],
)
def test_formats_duration(self, seconds_ago: int, expected: str) -> None:
@@ -106,10 +115,11 @@ class TestFormatDuration:
assert _format_duration(timestamp, self._NOW) == "unknown duration"
@pytest.mark.usefixtures("maubot_plugin")
class TestSubscribeCommand:
"""Subscribe command end-to-end via maubot."""
async def test_subscribe_valid_stream(self, maubot_test_bot, maubot_plugin) -> None:
async def test_subscribe_valid_stream(self, maubot_test_bot: TestBot) -> None:
"""Subscribe to a valid Owncast stream."""
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
with aioresponses() as mocked:
@@ -125,9 +135,7 @@ class TestSubscribeCommand:
"stream.logal.dev goes live."
)
async def test_subscribe_invalid_stream(
self, maubot_test_bot, maubot_plugin
) -> None:
async def test_subscribe_invalid_stream(self, maubot_test_bot: TestBot) -> None:
"""Reject subscription to an invalid Owncast instance."""
status_url = f"https://invalid.com{_OWNCAST_STATUS_PATH}"
with aioresponses() as mocked:
@@ -142,9 +150,7 @@ class TestSubscribeCommand:
"instance is offline."
)
async def test_subscribe_already_subscribed(
self, maubot_test_bot, maubot_plugin
) -> None:
async def test_subscribe_already_subscribed(self, maubot_test_bot: TestBot) -> None:
"""Reject duplicate subscription in the same room."""
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
with aioresponses() as mocked:
@@ -163,7 +169,7 @@ class TestSubscribeCommand:
)
async def test_subscribe_existing_stream_new_room(
self, maubot_test_bot, maubot_plugin
self, maubot_test_bot: TestBot
) -> None:
"""Skip instance validation when subscribing from a new room."""
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
@@ -189,10 +195,11 @@ class TestSubscribeCommand:
)
@pytest.mark.usefixtures("maubot_plugin")
class TestUnsubscribeCommand:
"""Unsubscribe command end-to-end via maubot."""
async def test_unsubscribe_existing(self, maubot_test_bot, maubot_plugin) -> None:
async def test_unsubscribe_existing(self, maubot_test_bot: TestBot) -> None:
"""Unsubscribe from a subscribed stream."""
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
with aioresponses() as mocked:
@@ -210,9 +217,7 @@ class TestUnsubscribeCommand:
"longer receive notifications for stream.logal.dev."
)
async def test_unsubscribe_not_subscribed(
self, maubot_test_bot, maubot_plugin
) -> None:
async def test_unsubscribe_not_subscribed(self, maubot_test_bot: TestBot) -> None:
"""Reject unsubscribe from a non-subscribed stream."""
await maubot_test_bot.send("!unsubscribe unknown.com")
@@ -222,10 +227,11 @@ class TestUnsubscribeCommand:
)
@pytest.mark.usefixtures("maubot_plugin")
class TestSubscriptionsCommand:
"""Subscriptions listing command end-to-end via maubot."""
async def test_no_subscriptions(self, maubot_test_bot, maubot_plugin) -> None:
async def test_no_subscriptions(self, maubot_test_bot: TestBot) -> None:
"""Show help text when no subscriptions exist."""
await maubot_test_bot.send("!subscriptions")
@@ -237,7 +243,9 @@ class TestSubscriptionsCommand:
)
@time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC))
async def test_shows_online_stream(self, maubot_test_bot, maubot_plugin) -> None:
async def test_shows_online_stream(
self, maubot_test_bot: TestBot, maubot_plugin: OwncastSentry
) -> None:
"""Show stream details including title and duration."""
# Subscribe first
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
@@ -273,7 +281,7 @@ class TestSubscriptionsCommand:
@time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC))
async def test_escapes_markdown_in_stream_name_and_title(
self, maubot_test_bot, maubot_plugin
self, maubot_test_bot: TestBot, maubot_plugin: OwncastSentry
) -> None:
"""Render stream name and title as literal text in command output."""
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
@@ -307,7 +315,9 @@ class TestSubscriptionsCommand:
assert "Title: `code` &gt; quote #tag" in content.formatted_body
@time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC))
async def test_shows_offline_stream(self, maubot_test_bot, maubot_plugin) -> None:
async def test_shows_offline_stream(
self, maubot_test_bot: TestBot, maubot_plugin: OwncastSentry
) -> None:
"""Show offline status for non-live streams."""
# Subscribe first
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
@@ -340,7 +350,7 @@ class TestSubscriptionsCommand:
)
async def test_shows_offline_stream_without_disconnect_time(
self, maubot_test_bot, maubot_plugin
self, maubot_test_bot: TestBot
) -> None:
"""Show offline status without duration before first poll completes."""
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
@@ -364,7 +374,9 @@ class TestSubscriptionsCommand:
"instances, use `!unsubscribe <domain>`"
)
async def test_shows_unknown_stream(self, maubot_test_bot, maubot_plugin) -> None:
async def test_shows_unknown_stream(
self, maubot_test_bot: TestBot, maubot_plugin: OwncastSentry
) -> None:
"""Show unknown status when instance has been unreachable."""
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
with aioresponses() as mocked:
@@ -394,7 +406,7 @@ class TestSubscriptionsCommand:
@time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC))
async def test_shows_multiple_subscriptions(
self, maubot_test_bot, maubot_plugin
self, maubot_test_bot: TestBot, maubot_plugin: OwncastSentry
) -> None:
"""List subscriptions alphabetically with mixed statuses."""
# Subscribe in reverse alphabetical order to verify sorted output
@@ -446,10 +458,11 @@ class TestSubscriptionsCommand:
)
@pytest.mark.usefixtures("maubot_plugin")
class TestLiveCommand:
"""Live streams listing command end-to-end via maubot."""
async def test_no_subscriptions(self, maubot_test_bot, maubot_plugin) -> None:
async def test_no_subscriptions(self, maubot_test_bot: TestBot) -> None:
"""Show help text when no subscriptions exist."""
await maubot_test_bot.send("!live")
@@ -460,7 +473,9 @@ class TestLiveCommand:
"instance, use `!subscribe <domain>`"
)
async def test_no_live_streams(self, maubot_test_bot, maubot_plugin) -> None:
async def test_no_live_streams(
self, maubot_test_bot: TestBot, maubot_plugin: OwncastSentry
) -> None:
"""Show 'no live' message when all streams are offline."""
# Subscribe first
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
@@ -490,7 +505,9 @@ class TestLiveCommand:
)
@time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC))
async def test_shows_live_stream(self, maubot_test_bot, maubot_plugin) -> None:
async def test_shows_live_stream(
self, maubot_test_bot: TestBot, maubot_plugin: OwncastSentry
) -> None:
"""Show live stream with title and duration."""
# Subscribe first
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
@@ -524,7 +541,7 @@ class TestLiveCommand:
@time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC))
async def test_shows_multiple_live_streams(
self, maubot_test_bot, maubot_plugin
self, maubot_test_bot: TestBot, maubot_plugin: OwncastSentry
) -> None:
"""List live streams alphabetically with different durations."""
# Subscribe in reverse alphabetical order to verify sorted output
+4 -1
View File
@@ -256,6 +256,7 @@ class TestFormatMessage:
title: str,
domain: str,
tags: list[str],
*,
title_change: bool,
expected: str,
subscription_repo: SubscriptionRepository,
@@ -264,7 +265,9 @@ class TestFormatMessage:
service = _make_service(
client=_StubMatrixClient(), subscription_repo=subscription_repo
)
result = service._format_message(name, title, domain, tags, title_change)
result = service._format_message(
name, title, domain, tags, title_change=title_change
)
assert result == expected
+1 -1
View File
@@ -45,7 +45,7 @@ class _ChunkedContent:
"""Store chunks to return from iter_chunked."""
self._chunks = chunks
async def iter_chunked(self, size: int) -> AsyncIterator[bytes]:
async def iter_chunked(self, _size: int) -> AsyncIterator[bytes]:
"""Yield chunks using the interface aiohttp exposes."""
for chunk in self._chunks:
yield chunk
+84 -16
View File
@@ -28,6 +28,14 @@ from owncastsentry.notification_service import (
from owncastsentry.stream_monitor import (
_CLEANUP_DELETE_THRESHOLD,
_CLEANUP_WARNING_THRESHOLD,
_QUERY_EVERY_2_MINUTES_FAILURE_LIMIT,
_QUERY_EVERY_2_MINUTES_INTERVAL,
_QUERY_EVERY_3_MINUTES_FAILURE_LIMIT,
_QUERY_EVERY_3_MINUTES_INTERVAL,
_QUERY_EVERY_5_MINUTES_FAILURE_LIMIT,
_QUERY_EVERY_5_MINUTES_INTERVAL,
_QUERY_EVERY_15_MINUTES_INTERVAL,
_QUERY_EVERY_MINUTE_FAILURE_LIMIT,
_TEMPORARY_OFFLINE_NOTIFICATION_COOLDOWN,
StreamMonitor,
_should_query_stream,
@@ -122,25 +130,85 @@ class TestShouldQueryStream:
[
pytest.param(0, True, id="counter-0-always-query"),
pytest.param(1, True, id="counter-1-always-query"),
pytest.param(4, True, id="counter-4-always-query"),
pytest.param(5, False, id="counter-5-skip-odd"),
pytest.param(6, True, id="counter-6-query-even"),
pytest.param(9, False, id="counter-9-skip-odd"),
pytest.param(10, False, id="counter-10-skip-not-mod-3"),
pytest.param(12, True, id="counter-12-query-mod-3"),
pytest.param(14, False, id="counter-14-skip-not-mod-3"),
pytest.param(15, True, id="counter-15-query-mod-5"),
pytest.param(16, False, id="counter-16-skip-not-mod-5"),
pytest.param(20, True, id="counter-20-query-mod-5"),
pytest.param(29, False, id="counter-29-skip-not-mod-5"),
pytest.param(30, True, id="counter-30-query-mod-15"),
pytest.param(31, False, id="counter-31-skip-not-mod-15"),
pytest.param(45, True, id="counter-45-query-mod-15"),
pytest.param(
_QUERY_EVERY_MINUTE_FAILURE_LIMIT,
True,
id="last-every-minute-counter",
),
pytest.param(
_QUERY_EVERY_MINUTE_FAILURE_LIMIT + 1,
False,
id="first-two-minute-counter-skips-odd",
),
pytest.param(
_QUERY_EVERY_2_MINUTES_INTERVAL * 3,
True,
id="two-minute-tier-queries-even",
),
pytest.param(
_QUERY_EVERY_2_MINUTES_FAILURE_LIMIT,
False,
id="last-two-minute-counter-skips-odd",
),
pytest.param(
_QUERY_EVERY_2_MINUTES_FAILURE_LIMIT + 1,
False,
id="first-three-minute-counter-skips-non-multiple",
),
pytest.param(
_QUERY_EVERY_3_MINUTES_INTERVAL * 4,
True,
id="three-minute-tier-queries-multiple",
),
pytest.param(
_QUERY_EVERY_3_MINUTES_FAILURE_LIMIT,
False,
id="last-three-minute-counter-skips-non-multiple",
),
pytest.param(
_QUERY_EVERY_3_MINUTES_FAILURE_LIMIT + 1,
True,
id="first-five-minute-counter-queries-multiple",
),
pytest.param(
_QUERY_EVERY_3_MINUTES_FAILURE_LIMIT + 2,
False,
id="five-minute-tier-skips-non-multiple",
),
pytest.param(
_QUERY_EVERY_5_MINUTES_INTERVAL * 4,
True,
id="five-minute-tier-queries-multiple",
),
pytest.param(
_QUERY_EVERY_5_MINUTES_FAILURE_LIMIT,
False,
id="last-five-minute-counter-skips-non-multiple",
),
pytest.param(
_QUERY_EVERY_5_MINUTES_FAILURE_LIMIT + 1,
True,
id="first-fifteen-minute-counter-queries-multiple",
),
pytest.param(
_QUERY_EVERY_5_MINUTES_FAILURE_LIMIT + 2,
False,
id="fifteen-minute-tier-skips-non-multiple",
),
pytest.param(
_QUERY_EVERY_15_MINUTES_INTERVAL * 3,
True,
id="fifteen-minute-tier-queries-multiple",
),
pytest.param(100, False, id="counter-100-skip-not-mod-15"),
pytest.param(105, True, id="counter-105-query-mod-15"),
pytest.param(
_QUERY_EVERY_15_MINUTES_INTERVAL * 7,
True,
id="fifteen-minute-tier-later-multiple",
),
],
)
def test_backoff_tiers(self, counter: int, expected: bool) -> None:
def test_backoff_tiers(self, counter: int, *, expected: bool) -> None:
"""Return the expected query decision for each backoff tier."""
assert _should_query_stream(counter) == expected