feat(proxy): expose retry delay configuration (#2077)

## Description

Expose Headroom's existing retry-delay configuration through the proxy
CLI and environment so operators can tune upstream backoff without
changing code. Existing 1000 ms / 30000 ms defaults remain unchanged.

Closes #2030

## Type of Change

- [ ] Bug fix (non-breaking change that fixes an issue)
- [x] New feature (non-breaking change that adds functionality)
- [ ] Breaking change
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring

## Changes Made

- add CLI flags for initial and maximum upstream retry delays
- support `HEADROOM_RETRY_BASE_DELAY_MS` and
`HEADROOM_RETRY_MAX_DELAY_MS`
- validate non-negative values and forward them into `ProxyConfig`
- cover explicit CLI values and environment-variable wiring

## Testing

- [x] Focused unit tests pass (`pytest`)
- [x] Touched-file linting passes (`ruff check`)
- [ ] Type checking passes (`mypy headroom`) — not run
- [x] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
uv run pytest tests/test_cli_proxy_improvements.py::TestRetryDelayValidation tests/test_cli_proxy_improvements.py::TestNewEnvVarWiring::test_headroom_retry_delays_from_env -q
4 passed

uv run ruff check headroom/cli/proxy.py tests/test_cli_proxy_improvements.py
All checks passed!

uv run ruff format --check headroom/cli/proxy.py tests/test_cli_proxy_improvements.py
2 files already formatted
```

## Real Behavior Proof

- Environment: local Python test environment
- Exact command / steps: invoke the focused Click CLI tests with
explicit delay flags and `HEADROOM_RETRY_*` environment variables
- Observed result: all four cases passed; parsed values reached
`ProxyConfig`, while invalid negative values were rejected
- Not tested: live upstream retry timing or the full repository test
suite

## 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] My changes generate no new warnings in the scoped checks
- [x] I have added tests that prove the feature works
- [x] Relevant existing and new unit tests pass locally
- [ ] Documentation and changelog updates — not applicable for these
self-documenting CLI options

## Additional Notes

The existing runtime backoff helper still caps the base delay against
the maximum. This change only exposes values already supported by
`ProxyConfig`.

Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
This commit is contained in:
Andrew Barnes 2026-07-13 09:37:23 -04:00 committed by GitHub
parent f53f720eb5
commit 099c66432b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 55 additions and 0 deletions

View file

@ -373,6 +373,26 @@ def dashboard(port: int, no_open: bool) -> None:
"Env: HEADROOM_RETRY_MAX_ATTEMPTS."
),
)
@click.option(
"--retry-base-delay-ms",
type=click.IntRange(min=0),
default=None,
envvar="HEADROOM_RETRY_BASE_DELAY_MS",
help=(
"Initial upstream retry delay in milliseconds (minimum: 0, default: 1000). "
"Env: HEADROOM_RETRY_BASE_DELAY_MS."
),
)
@click.option(
"--retry-max-delay-ms",
type=click.IntRange(min=0),
default=None,
envvar="HEADROOM_RETRY_MAX_DELAY_MS",
help=(
"Maximum upstream retry delay in milliseconds (minimum: 0, default: 30000). "
"Env: HEADROOM_RETRY_MAX_DELAY_MS."
),
)
@click.option(
"--request-timeout-seconds",
type=int,
@ -876,6 +896,8 @@ def proxy(
no_subscription_tracking: bool,
subscription_poll_interval: int | None,
retry_max_attempts: int | None,
retry_base_delay_ms: int | None,
retry_max_delay_ms: int | None,
request_timeout_seconds: int | None,
connect_timeout_seconds: int | None,
anthropic_buffered_request_timeout_seconds: int | None,
@ -1129,6 +1151,8 @@ def proxy(
subscription_poll_interval if subscription_poll_interval is not None else 300
),
retry_max_attempts=retry_max_attempts if retry_max_attempts is not None else 3,
retry_base_delay_ms=retry_base_delay_ms if retry_base_delay_ms is not None else 1000,
retry_max_delay_ms=retry_max_delay_ms if retry_max_delay_ms is not None else 30000,
request_timeout_seconds=request_timeout_seconds
if request_timeout_seconds is not None and request_timeout_seconds > 0
else 300,

View file

@ -193,6 +193,23 @@ class TestRetryMaxAttemptsValidation:
assert result.exit_code != 0
class TestRetryDelayValidation:
def test_retry_delays_are_forwarded(self, runner: CliRunner, mock_run_server: dict) -> None:
result = runner.invoke(
main,
["proxy", "--retry-base-delay-ms", "250", "--retry-max-delay-ms", "5000"],
catch_exceptions=False,
)
assert result.exit_code == 0, result.output
assert mock_run_server["config"].retry_base_delay_ms == 250
assert mock_run_server["config"].retry_max_delay_ms == 5000
@pytest.mark.parametrize("option", ["--retry-base-delay-ms", "--retry-max-delay-ms"])
def test_negative_delay_is_rejected(self, runner: CliRunner, option: str) -> None:
result = runner.invoke(main, ["proxy", option, "-1"])
assert result.exit_code != 0
class TestConnectTimeoutSecondsValidation:
"""--connect-timeout-seconds should accept 1-300, reject outside that range."""
@ -350,6 +367,20 @@ class TestNewEnvVarWiring:
assert result.exit_code == 0, result.output
assert mock_run_server["config"].retry_max_attempts == 5
def test_headroom_retry_delays_from_env(self, runner: CliRunner, mock_run_server: dict) -> None:
result = runner.invoke(
main,
["proxy"],
env={
"HEADROOM_RETRY_BASE_DELAY_MS": "125",
"HEADROOM_RETRY_MAX_DELAY_MS": "8000",
},
catch_exceptions=False,
)
assert result.exit_code == 0, result.output
assert mock_run_server["config"].retry_base_delay_ms == 125
assert mock_run_server["config"].retry_max_delay_ms == 8000
def test_headroom_connect_timeout_from_env(
self, runner: CliRunner, mock_run_server: dict
) -> None: