Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# SPDX-License-Identifier: LGPL-3.0-only
# Copyright (c) 2026 Mirrowel

name: Tests

on:
push:
branches:
- main
- dev
paths:
- 'src/rotator_library/**'
- 'tests/**'
- 'pytest.ini'
- 'requirements.txt'
- '.github/workflows/tests.yml'
pull_request:
paths:
- 'src/rotator_library/**'
- 'tests/**'
- 'pytest.ini'
- 'requirements.txt'
- '.github/workflows/tests.yml'

jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
Comment thread
greptile-apps[bot] marked this conversation as resolved.
matrix:
os: [ubuntu-latest, windows-latest]
python-version: ['3.12', '3.13']

steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
Comment on lines +35 to +41

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Pin GitHub Actions by commit SHA and disable persisted checkout credentials.

Using floating action tags (@v4, @v5) and default credential persistence weakens CI supply-chain security posture.

Suggested hardening patch
-      - name: Check out repository
-        uses: actions/checkout@v4
+      - name: Check out repository
+        uses: actions/checkout@<FULL_LENGTH_COMMIT_SHA>
+        with:
+          persist-credentials: false

-      - name: Set up Python ${{ matrix.python-version }}
-        uses: actions/setup-python@v5
+      - name: Set up Python ${{ matrix.python-version }}
+        uses: actions/setup-python@<FULL_LENGTH_COMMIT_SHA>
🧰 Tools
🪛 zizmor (1.25.2)

[warning] 33-34: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)


[error] 34-34: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)


[error] 37-37: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/tests.yml around lines 33 - 39, Replace the floating
action tag references with pinned commit SHAs for improved supply-chain
security. Specifically, change the actions/checkout@v4 reference to use a full
commit SHA instead of the floating tag, and similarly update the
actions/setup-python@v5 reference to use a pinned commit SHA. Additionally, add
persist-credentials: false as a configuration option to the checkout action step
to disable persisted credentials and reduce the security surface.

Source: Linters/SAST tools


- name: Install dependencies
shell: bash
run: |
python -m pip install --upgrade pip
pip install pytest pytest-asyncio

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 pytest and pytest-asyncio are installed without version constraints, so a future release of either package (e.g. a breaking pytest-asyncio 1.x) could silently break CI on the next run without any change to the repository. Pinning or anchoring the versions keeps builds reproducible and makes upgrades deliberate.

Suggested change
pip install pytest pytest-asyncio
pip install "pytest>=8,<9" "pytest-asyncio>=0.23,<1"

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

# Install rotator_library dependencies (skip editable install line)
grep -v -- '-e src/rotator_library' requirements.txt > temp_requirements.txt
pip install -r temp_requirements.txt
Comment on lines +49 to +50

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make requirements filtering non-fatal when no lines remain.

grep -v exits with status 1 when nothing is selected, which can fail this job despite valid input (for example, if the file only contains the editable line).

Suggested robust alternative
-          grep -v -- '-e src/rotator_library' requirements.txt > temp_requirements.txt
-          pip install -r temp_requirements.txt
+          python - <<'PY'
+from pathlib import Path
+src = Path("requirements.txt")
+dst = Path("temp_requirements.txt")
+lines = [ln for ln in src.read_text(encoding="utf-8").splitlines()
+         if ln.strip() != "-e src/rotator_library"]
+dst.write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8")
+PY
+          if [ -s temp_requirements.txt ]; then pip install -r temp_requirements.txt; fi
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/tests.yml around lines 47 - 48, The grep command in the
requirements filtering step exits with status 1 when no lines match the filter
pattern, causing the workflow to fail even with valid input. Make the grep
command non-fatal by appending || true to the grep -v -- '-e
src/rotator_library' requirements.txt command so that it succeeds regardless of
whether any lines are filtered out, allowing the subsequent pip install to
proceed with valid (possibly empty) filtered requirements.

pip install -e src/rotator_library
Comment on lines +43 to +51

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR checklist states the CI "matches project's existing CI patterns (uv, Python 3.12+)", but this step uses raw pip, whereas the established build.yml uses astral-sh/setup-uv@v4 with enable-cache: true (build.yml:127-145) and the same temp_requirements.txt approach. Adopting uv here would (a) honour the stated checklist item, (b) enable dependency caching for faster CI, and (c) keep the two workflows consistent.

This requires adding a Set up uv step (astral-sh/setup-uv@v4) + uv venv before this step (replacing the setup-python action), and switching the Run tests step to uv run --python .venv pytest -v, mirroring build.yml. The install block itself then becomes:

Suggested change
- name: Install dependencies
shell: bash
run: |
python -m pip install --upgrade pip
pip install pytest pytest-asyncio
# Install rotator_library dependencies (skip editable install line)
grep -v -- '-e src/rotator_library' requirements.txt > temp_requirements.txt
pip install -r temp_requirements.txt
pip install -e src/rotator_library
- name: Install dependencies
shell: bash
run: |
grep -v -- '-e src/rotator_library' requirements.txt > temp_requirements.txt
uv pip install --python .venv -r temp_requirements.txt
uv pip install --python .venv pytest pytest-asyncio
uv pip install --python .venv -e src/rotator_library

rm -f temp_requirements.txt

- name: Run tests
shell: bash
run: pytest -v
24 changes: 24 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,37 @@ All new source files **must** include the appropriate license header:
- Use type hints where practical
- Keep functions focused and well-documented

### Running Tests

The project uses `pytest` for testing. The test suite covers self-contained modules
in the resilience library (`rotator_library`).

```bash
# Install test dependencies
pip install pytest pytest-asyncio

# Run the full test suite
pytest

# Run a specific test module
pytest tests/test_request_sanitizer.py

# Run with verbose output
pytest -v
```

When adding new features or fixing bugs, include tests in the `tests/` directory.
Follow the existing test file naming convention (`test_*.py`) and use descriptive
class names to group related tests.

## Pull Requests

1. Fork the repository and create a feature branch
2. Make your changes with clear commit messages
3. Reference related issues in commits: `feat(providers): add X provider (#123)`
4. Open a PR with a clear description of what changed and why
5. Ensure your changes include necessary documentation updates
6. Add or update tests for any changed behavior

## Reporting Issues

Expand Down
9 changes: 9 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# SPDX-License-Identifier: LGPL-3.0-only
# Copyright (c) 2026 Mirrowel

[pytest]
testpaths = tests
asyncio_mode = auto
python_files = test_*.py
python_classes = Test*
python_functions = test_*
27 changes: 27 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# SPDX-License-Identifier: LGPL-3.0-only
# Copyright (c) 2026 Mirrowel

"""
Pytest configuration and shared fixtures for the rotator_library test suite.

This conftest adds the source directories to sys.path so that both
standalone modules (request_sanitizer, session_tracking, etc.) and
package-qualified modules (rotator_library.core.utils) can be imported
without requiring a full editable install of every dependency.
"""

import sys
from pathlib import Path

# Resolve the repository root (tests/ -> parent)
_REPO_ROOT = Path(__file__).resolve().parent.parent
_SRC_ROOT = _REPO_ROOT / "src"
_LIB_ROOT = _SRC_ROOT / "rotator_library"

# Prepend source paths so that:
# - `import rotator_library.X` works (via _SRC_ROOT)
# - `from request_sanitizer import ...` works (via _LIB_ROOT)
for _path in (_SRC_ROOT, _LIB_ROOT):
_str = str(_path)
if _str not in sys.path:
sys.path.insert(0, _str)
Comment on lines +21 to +27
Comment on lines +24 to +27

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The sys.path.insert(0, _str) loop processes _SRC_ROOT first and then _LIB_ROOT, so after both iterations _LIB_ROOT ends up at index 0 — the reverse of what the comments imply. The comments say "import rotator_library.X works via _SRC_ROOT" and "from request_sanitizer import … works via _LIB_ROOT", but the actual search order is [_LIB_ROOT, _SRC_ROOT, …]. It works today because _LIB_ROOT doesn't contain a rotator_library/ subdirectory, so the package lookup naturally falls through to _SRC_ROOT. Reversing the loop order makes the code match the comments and makes the intent explicit.

Suggested change
for _path in (_SRC_ROOT, _LIB_ROOT):
_str = str(_path)
if _str not in sys.path:
sys.path.insert(0, _str)
for _path in (_LIB_ROOT, _SRC_ROOT):
_str = str(_path)
if _str not in sys.path:
sys.path.insert(0, _str)

128 changes: 128 additions & 0 deletions tests/test_cooldown_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# SPDX-License-Identifier: LGPL-3.0-only
# Copyright (c) 2026 Mirrowel

"""
Tests for cooldown_manager.CooldownManager.

CooldownManager tracks per-provider cooldown periods to handle
IP-based rate limiting. When a 429 is received, all requests to
that provider are paused for a configurable duration.
"""

import asyncio
import time
from unittest.mock import patch
Comment on lines +13 to +14

import pytest

from cooldown_manager import CooldownManager


class TestCooldownBasics:
"""Basic functionality tests."""

@pytest.mark.asyncio
async def test_provider_not_cooling_down_initially(self):
cm = CooldownManager()
assert await cm.is_cooling_down("openai") is False

@pytest.mark.asyncio
async def test_start_cooldown_makes_provider_cooling(self):
cm = CooldownManager()
await cm.start_cooldown("openai", 60)
assert await cm.is_cooling_down("openai") is True

@pytest.mark.asyncio
async def test_different_providers_independent(self):
cm = CooldownManager()
await cm.start_cooldown("openai", 60)
assert await cm.is_cooling_down("openai") is True
assert await cm.is_cooling_down("anthropic") is False


class TestCooldownRemaining:
"""Tests for remaining time calculations."""

@pytest.mark.asyncio
async def test_remaining_cooldown_positive(self):
cm = CooldownManager()
await cm.start_cooldown("openai", 100)
remaining = await cm.get_cooldown_remaining("openai")
# Should be close to 100 but slightly less due to execution time
assert 90 <= remaining <= 100

@pytest.mark.asyncio
async def test_remaining_cooldown_zero_if_not_cooling(self):
cm = CooldownManager()
remaining = await cm.get_cooldown_remaining("openai")
assert remaining == 0

@pytest.mark.asyncio
async def test_remaining_cooldown_after_expiry(self):
cm = CooldownManager()
await cm.start_cooldown("openai", 0)
# With duration=0, the cooldown is set to exactly now
# By the time we check, time has advanced
await asyncio.sleep(0.01)
remaining = await cm.get_cooldown_remaining("openai")
assert remaining == 0

@pytest.mark.asyncio
async def test_get_remaining_cooldown_alias(self):
"""get_remaining_cooldown is a backward-compatible alias."""
cm = CooldownManager()
await cm.start_cooldown("openai", 50)
r1 = await cm.get_cooldown_remaining("openai")
r2 = await cm.get_remaining_cooldown("openai")
assert abs(r1 - r2) < 1 # Should be nearly identical


class TestCooldownExpiry:
"""Tests for cooldown expiration behavior."""

@pytest.mark.asyncio
async def test_cooldown_expires(self):
cm = CooldownManager()
await cm.start_cooldown("openai", 1)
assert await cm.is_cooling_down("openai") is True
# Patch the clock to advance past expiry without real sleeping
with patch("cooldown_manager.time.time", return_value=time.time() + 1.1):
assert await cm.is_cooling_down("openai") is False

Comment thread
coderabbitai[bot] marked this conversation as resolved.
@pytest.mark.asyncio
async def test_cooldown_extends_on_new_start(self):
cm = CooldownManager()
await cm.start_cooldown("openai", 5)
r1 = await cm.get_cooldown_remaining("openai")

await cm.start_cooldown("openai", 100)
r2 = await cm.get_cooldown_remaining("openai")

assert r2 > r1


class TestCooldownConcurrency:
"""Tests for concurrent access safety."""

@pytest.mark.asyncio
async def test_concurrent_start_and_check(self):
cm = CooldownManager()
# Multiple concurrent operations should not cause issues
await asyncio.gather(
cm.start_cooldown("openai", 10),
cm.start_cooldown("anthropic", 20),
cm.is_cooling_down("openai"),
cm.is_cooling_down("anthropic"),
cm.is_cooling_down("gemini"),
)
assert await cm.is_cooling_down("openai") is True
assert await cm.is_cooling_down("anthropic") is True
assert await cm.is_cooling_down("gemini") is False

@pytest.mark.asyncio
async def test_concurrent_reads(self):
cm = CooldownManager()
await cm.start_cooldown("openai", 10)
# Many concurrent reads
results = await asyncio.gather(*[cm.is_cooling_down("openai") for _ in range(100)])
assert all(results)
Loading