test(retry): cover Retry-After cap, HTTP-date fallback, TimeoutException#133
Conversation
…ception paths Backfills the regression test surface flagged by the Wave 5 coverage audit: - F-Q-01 (HIGH): assert Retry-After: 9999 clamps to retry_max_delay so a hostile/misconfigured server cannot park the SDK. Sync + async. - F-Q-02 (MED): assert HTTP-date Retry-After falls back to computed backoff and the transport still retries to success. Sync + async. - F-Q-03 (HIGH): assert httpx.TimeoutException retries on GET (idempotent) but raises immediately on POST (duplicate-order risk). Sync + async. All paths sleep through monkeypatched time.sleep / asyncio.sleep so the assertions check actual delays, not wall-clock timing. Closes #97 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code Review — PR #133Overall: LGTM with minor notes. This is a clean, well-scoped regression test PR. The tests target real safety properties, follow existing project patterns exactly, and all assertions are meaningful. Good candidate for merge after the minor items below. What the PR doesAdds 8 tests (4 sync / 4 async) covering three retry-safety behaviors that were implemented but untested:
All three map directly to real failure modes. Worth having. Bugs / CorrectnessNone found. The async monkeypatch target Suggestions1. F-Q-02 assertion window is tight but non-deterministic # both sync and async
assert 0.0 <= sleeps[0] <= 0.01
# uniform(0, retry_base_delay * 2**0) = uniform(0, 0.01)
assert 0.0 <= sleeps[0] <= 0.01This helps a future reader who changes 2. The test confirms # async version example:
sleeps: list[float] = []
async def fake_sleep(d: float) -> None:
sleeps.append(d)
monkeypatch.setattr("asyncio.sleep", fake_sleep)
# ... rest of test ...
assert route.call_count == 2
assert len(sleeps) == 1
assert sleeps[0] >= 0.03. Redundant # test_get_retries_on_timeout (async)
async def fake_sleep(d: float) -> None:
return None # <- implicit
4. Block comment references issue numbers and F-Q codes # --- Regression: issue #97 -------------------------------------------
# Async counterparts of F-Q-01 (cap), F-Q-02 (HTTP-date), and F-Q-03Per CLAUDE.md: "Don't reference the current task, fix, or callers… those belong in the PR description and rot as the codebase evolves." The PR description already carries this context well. The F-Q codes aren't defined anywhere in the codebase itself, so they'll be opaque to future readers. The per-test docstrings capture the important WHY already. Consider dropping the block header and F-Q references from the inline comments. Coverage assessmentThe three behaviors covered are the right ones. No gaps relative to the stated scope. DELETE/HEAD timeout retry is covered by the existing SummaryItems 2 (missing sleep assertion in timeout retry test) and 4 (comment style) are worth addressing before merge. Item 1 is a nice-to-have. Item 3 is a true nit. |
…omments Per bot review on #133: - test_get_retries_on_timeout (sync + async) now captures sleeps via monkeypatch and asserts len(sleeps) == 1. Without this, removing the time.sleep(delay) / asyncio.sleep(delay) line in the timeout-retry path would leave the test green. - Dropped block-comment header that referenced issue #97 + F-Q codes (CLAUDE.md §3: don't reference current task in code, that's PR-body territory; F-Q codes aren't defined in the codebase). Skipped: - F-Q-02 magic-number explanation comment (bot called it nice-to-have; the docstring already names the formula). - Redundant "return None" in fake_sleep — replaced by sleeps.append(d) anyway. uv run pytest tests/test_client.py tests/test_async_client.py -k retry: 36 passed. ruff clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Addressed in latest commit:
Skipped (justified):
|
Code Review — PR #133: Retry regression testsSummary: Wave 2 of issue #97 — adds 8 unit tests (4 sync + 4 async) covering three retry-safety paths that were shipped in PRs #111/#112 but had no explicit coverage. Test-only PR; no production code changes. What the PR gets right
IssuesMedium —
|
| Path | Sync | Async | Notes |
|---|---|---|---|
Retry-After: 9999 clamped to retry_max_delay |
✅ | ✅ | HIGH security property |
HTTP-date Retry-After falls back to backoff |
✅ | ✅ | MED; backoff window correct |
GET retries on TimeoutException |
✅ | ✅ | Delay assertion is a tautology (see above) |
POST does NOT retry on TimeoutException |
✅ | ✅ | Tight; call_count == 1 is good |
Verdict
Approve with suggestions. The PR fills real gaps, is well-structured, and doesn't touch production code. The timeout retry assertion is the only substantive issue — it passes today but would survive a regression that introduced an unreasonably large backoff. The remaining items are minor consistency and readability improvements.
Summary
Wave 2 #97: regression tests for three retry-safety paths that landed in PRs #111 / #112 but had no explicit unit-test coverage.
+8 tests (4 sync in
tests/test_client.py, 4 async intests/test_async_client.py):test_429_retry_after_caps_at_retry_max_delay(sync + async). MocksRetry-After: 9999, asserts the recorded sleep equalsretry_max_delay=0.1, not 9999. Prevents a malicious or misconfigured server from making the client sleep arbitrarily long.test_429_retry_after_http_date_falls_back_to_backoff(sync + async). HTTP-date format header (not int seconds); transport still retries, recorded sleep lands in the Full Jitter window[0, base * 2**0].test_get_retries_on_timeout+test_post_not_retried_on_timeout(sync + async). GET retries onhttpx.TimeoutException; POST raises wrappedKalshiErroron first call (route.call_count == 1) since duplicate-POST risk overrides retry.Pattern matches existing
test_429_retry_after_zero_is_honored_end_to_end: sync patcheskalshi._base_client.time.sleep, async patchesasyncio.sleep(the module is imported inside the async transport method).Closes #97
Test plan
uv run ruff check .cleanuv run mypy kalshi/clean (strict)