headroom/tests/test_proxy_config_rate_limit.py
Abhay Singh 8a71947023
fix(proxy): reject rate_limit_requests_per_minute=0 when limiting is enabled (#2142)
## Description

A `rate_limit_requests_per_minute` of 0 makes the proxy return a 500 on
every rate-limited request instead of failing configuration early.

The token-bucket wait computation divides by the per-minute rate:

```python
def consume_from_bucket(*, available_tokens, requested_tokens, rate_per_minute):
    if available_tokens >= requested_tokens:
        return True, available_tokens - requested_tokens, 0.0
    wait_seconds = (requested_tokens - available_tokens) * (60.0 / rate_per_minute)
    return False, available_tokens, wait_seconds
```

With `rate_limit_requests_per_minute == 0`, the bucket initializes to 0
tokens, so the first request reaches the division and raises
`ZeroDivisionError`. The CLI guards `--rpm` with
`click.IntRange(min=1)`, but `HEADROOM_PROXY_CONFIG_JSON` and
programmatic `ProxyConfig(...)` construction bypass that guard.

## Fix

Validate `rate_limit_requests_per_minute >= 1` in
`ProxyConfig.__post_init__` when `rate_limit_enabled`, mirroring the
existing `retry_max_attempts` validation. Bad enabled configs now fail
fast with a clear message. When rate limiting is disabled, `rpm=0`
remains inert and is not rejected.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- `headroom/proxy/models.py`: reject `rate_limit_requests_per_minute <
1` when `rate_limit_enabled`.
- `tests/test_proxy_config_rate_limit.py`: cover zero/negative enabled
values, disabled zero, and a valid enabled value.
- `CHANGELOG.md`: add a bug-fix entry.
- Merged current `main` to pick up the repository-wide memory factory
type annotation fix that was breaking the PR lint job.

## Testing

- [x] Unit tests pass (`pytest` focused locally; broader CI passed on
the pre-merge head and fresh CI is running on the main-merged head)
- [x] Linting passes (`ruff check` focused locally)
- [x] Type checking passes for the prior mypy blocker after merging
current `main`
- [x] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
uvx ruff@0.15.17 check headroom/proxy/models.py tests/test_proxy_config_rate_limit.py headroom/memory/factory.py
All checks passed!

uvx ruff@0.15.17 format --check headroom/proxy/models.py tests/test_proxy_config_rate_limit.py headroom/memory/factory.py
3 files already formatted

git diff --check headroomlabs/main...HEAD
# no output

uv run --extra dev python -m pytest tests/test_proxy_config_rate_limit.py -q
4 passed
```

## Real Behavior Proof

- Environment: Windows 11 review worktree, Python 3.13.3.
- Exact command / steps: ran the focused rate-limit config test file and
targeted lint/format checks.
- Observed result: enabled zero and negative rpm raise `ValueError`;
disabled zero is accepted; valid enabled rpm is accepted.
- Not tested: full suite; fresh CI is queued after the main merge.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

The validation is intentionally at the config boundary to match the
CLI's `IntRange(min=1)` contract and the existing fail-fast
`retry_max_attempts` check.

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-14 12:14:06 -04:00

30 lines
1.2 KiB
Python

"""ProxyConfig must reject a 0 requests-per-minute limit when rate limiting is on
(it would divide by zero in the token-bucket wait computation and 500 every
request), while leaving it inert when limiting is off."""
from __future__ import annotations
import pytest
from headroom.proxy.models import ProxyConfig
def test_zero_rpm_with_limiting_enabled_is_rejected():
with pytest.raises(ValueError, match="rate_limit_requests_per_minute must be >= 1"):
ProxyConfig(rate_limit_enabled=True, rate_limit_requests_per_minute=0)
def test_negative_rpm_with_limiting_enabled_is_rejected():
with pytest.raises(ValueError, match="rate_limit_requests_per_minute must be >= 1"):
ProxyConfig(rate_limit_enabled=True, rate_limit_requests_per_minute=-5)
def test_zero_rpm_is_inert_when_limiting_disabled():
# Limiting off -> the bucket is never consulted, so a 0 limit is harmless.
config = ProxyConfig(rate_limit_enabled=False, rate_limit_requests_per_minute=0)
assert config.rate_limit_requests_per_minute == 0
def test_valid_rpm_is_accepted():
config = ProxyConfig(rate_limit_enabled=True, rate_limit_requests_per_minute=60)
assert config.rate_limit_requests_per_minute == 60