Skip to content

Commit 5b27d96

Browse files
TexasCodingclaude
andcommitted
test(retry): cover Retry-After cap, HTTP-date fallback, and TimeoutException 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>
1 parent 643b6c5 commit 5b27d96

2 files changed

Lines changed: 173 additions & 0 deletions

File tree

tests/test_async_client.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from kalshi.errors import (
2020
AuthRequiredError,
2121
KalshiAuthError,
22+
KalshiError,
2223
KalshiServerError,
2324
KalshiValidationError,
2425
)
@@ -203,6 +204,100 @@ async def test_successful_request(
203204
assert resp.status_code == 200
204205
assert resp.json()["markets"][0]["ticker"] == "TEST"
205206

207+
# --- Regression: issue #97 -------------------------------------------
208+
# Async counterparts of F-Q-01 (cap), F-Q-02 (HTTP-date), and F-Q-03
209+
# (TimeoutException retry on GET, no retry on POST).
210+
211+
@respx.mock
212+
@pytest.mark.asyncio
213+
async def test_429_retry_after_caps_at_retry_max_delay(
214+
self, transport: AsyncTransport, monkeypatch: pytest.MonkeyPatch
215+
) -> None:
216+
"""Async cap: Retry-After: 9999 must clamp to retry_max_delay=0.1."""
217+
sleeps: list[float] = []
218+
219+
async def fake_sleep(d: float) -> None:
220+
sleeps.append(d)
221+
222+
monkeypatch.setattr("asyncio.sleep", fake_sleep)
223+
respx.get("https://test.kalshi.com/trade-api/v2/markets").mock(
224+
side_effect=[
225+
httpx.Response(429, headers={"Retry-After": "9999"}, json={"message": "rl"}),
226+
httpx.Response(200, json={"markets": []}),
227+
]
228+
)
229+
resp = await transport.request("GET", "/markets")
230+
assert resp.status_code == 200
231+
assert sleeps == [0.1], (
232+
f"Expected sleep clamped to retry_max_delay=0.1, got {sleeps!r}"
233+
)
234+
235+
@respx.mock
236+
@pytest.mark.asyncio
237+
async def test_429_retry_after_http_date_falls_back_to_backoff(
238+
self, transport: AsyncTransport, monkeypatch: pytest.MonkeyPatch
239+
) -> None:
240+
"""Async: HTTP-date unparseable; transport retries via computed backoff."""
241+
sleeps: list[float] = []
242+
243+
async def fake_sleep(d: float) -> None:
244+
sleeps.append(d)
245+
246+
monkeypatch.setattr("asyncio.sleep", fake_sleep)
247+
route = respx.get("https://test.kalshi.com/trade-api/v2/markets").mock(
248+
side_effect=[
249+
httpx.Response(
250+
429,
251+
headers={"Retry-After": "Wed, 21 Oct 2026 07:28:00 GMT"},
252+
json={"message": "rl"},
253+
),
254+
httpx.Response(200, json={"markets": []}),
255+
]
256+
)
257+
resp = await transport.request("GET", "/markets")
258+
assert resp.status_code == 200
259+
assert route.call_count == 2
260+
assert len(sleeps) == 1
261+
assert 0.0 <= sleeps[0] <= 0.01, (
262+
f"Expected backoff sleep in [0, 0.01], got {sleeps[0]!r}"
263+
)
264+
265+
@respx.mock
266+
@pytest.mark.asyncio
267+
async def test_get_retries_on_timeout(
268+
self, transport: AsyncTransport, monkeypatch: pytest.MonkeyPatch
269+
) -> None:
270+
"""Async GET retries on httpx.TimeoutException."""
271+
272+
async def fake_sleep(d: float) -> None:
273+
return None
274+
275+
monkeypatch.setattr("asyncio.sleep", fake_sleep)
276+
route = respx.get("https://test.kalshi.com/trade-api/v2/markets").mock(
277+
side_effect=[
278+
httpx.TimeoutException("read timed out"),
279+
httpx.Response(200, json={"markets": []}),
280+
]
281+
)
282+
resp = await transport.request("GET", "/markets")
283+
assert resp.status_code == 200
284+
assert route.call_count == 2
285+
286+
@respx.mock
287+
@pytest.mark.asyncio
288+
async def test_post_not_retried_on_timeout(
289+
self, transport: AsyncTransport
290+
) -> None:
291+
"""Async POST timeout raises immediately; no retry."""
292+
route = respx.post(
293+
"https://test.kalshi.com/trade-api/v2/portfolio/orders"
294+
).mock(side_effect=httpx.TimeoutException("read timed out"))
295+
with pytest.raises(KalshiError, match="timed out"):
296+
await transport.request(
297+
"POST", "/portfolio/orders", json={"ticker": "T"}
298+
)
299+
assert route.call_count == 1
300+
206301

207302
class TestAsyncTransportContextManager:
208303
@pytest.mark.asyncio

tests/test_client.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,84 @@ def test_successful_request(self, transport: SyncTransport) -> None:
277277
assert resp.status_code == 200
278278
assert resp.json()["markets"][0]["ticker"] == "TEST"
279279

280+
# --- Regression: issue #97 -------------------------------------------
281+
# F-Q-01: Retry-After must be capped at retry_max_delay so a hostile
282+
# server can't park the SDK for 9999s. F-Q-02: HTTP-date Retry-After
283+
# falls back to computed backoff and the transport still retries.
284+
# F-Q-03: httpx.TimeoutException retries only on idempotent methods.
285+
286+
@respx.mock
287+
def test_429_retry_after_caps_at_retry_max_delay(
288+
self, transport: SyncTransport, monkeypatch: pytest.MonkeyPatch
289+
) -> None:
290+
"""Hostile/misconfigured Retry-After is clamped to retry_max_delay."""
291+
sleeps: list[float] = []
292+
monkeypatch.setattr("kalshi._base_client.time.sleep", lambda d: sleeps.append(d))
293+
respx.get("https://test.kalshi.com/trade-api/v2/markets").mock(
294+
side_effect=[
295+
httpx.Response(429, headers={"Retry-After": "9999"}, json={"message": "rl"}),
296+
httpx.Response(200, json={"markets": []}),
297+
]
298+
)
299+
resp = transport.request("GET", "/markets")
300+
assert resp.status_code == 200
301+
# config fixture: retry_max_delay=0.1 — must clamp to that, NOT 9999.
302+
assert sleeps == [0.1], (
303+
f"Expected sleep clamped to retry_max_delay=0.1, got {sleeps!r}"
304+
)
305+
306+
@respx.mock
307+
def test_429_retry_after_http_date_falls_back_to_backoff(
308+
self, transport: SyncTransport, monkeypatch: pytest.MonkeyPatch
309+
) -> None:
310+
"""HTTP-date Retry-After unparseable; transport still retries via backoff."""
311+
sleeps: list[float] = []
312+
monkeypatch.setattr("kalshi._base_client.time.sleep", lambda d: sleeps.append(d))
313+
route = respx.get("https://test.kalshi.com/trade-api/v2/markets").mock(
314+
side_effect=[
315+
httpx.Response(
316+
429,
317+
headers={"Retry-After": "Wed, 21 Oct 2026 07:28:00 GMT"},
318+
json={"message": "rl"},
319+
),
320+
httpx.Response(200, json={"markets": []}),
321+
]
322+
)
323+
resp = transport.request("GET", "/markets")
324+
assert resp.status_code == 200
325+
assert route.call_count == 2
326+
# Backoff is Full Jitter: uniform(0, min(base*2**0, max)) = uniform(0, 0.01).
327+
assert len(sleeps) == 1
328+
assert 0.0 <= sleeps[0] <= 0.01, (
329+
f"Expected backoff sleep in [0, 0.01], got {sleeps[0]!r}"
330+
)
331+
332+
@respx.mock
333+
def test_get_retries_on_timeout(
334+
self, transport: SyncTransport, monkeypatch: pytest.MonkeyPatch
335+
) -> None:
336+
"""GET retries on httpx.TimeoutException (idempotent method)."""
337+
monkeypatch.setattr("kalshi._base_client.time.sleep", lambda d: None)
338+
route = respx.get("https://test.kalshi.com/trade-api/v2/markets").mock(
339+
side_effect=[
340+
httpx.TimeoutException("read timed out"),
341+
httpx.Response(200, json={"markets": []}),
342+
]
343+
)
344+
resp = transport.request("GET", "/markets")
345+
assert resp.status_code == 200
346+
assert route.call_count == 2
347+
348+
@respx.mock
349+
def test_post_not_retried_on_timeout(self, transport: SyncTransport) -> None:
350+
"""POST timeout raises immediately; no retry (duplicate-order risk)."""
351+
route = respx.post(
352+
"https://test.kalshi.com/trade-api/v2/portfolio/orders"
353+
).mock(side_effect=httpx.TimeoutException("read timed out"))
354+
with pytest.raises(KalshiError, match="timed out"):
355+
transport.request("POST", "/portfolio/orders", json={"ticker": "T"})
356+
assert route.call_count == 1
357+
280358

281359
class TestSyncTransportContextManager:
282360
def test_close(self, test_auth: KalshiAuth, config: KalshiConfig) -> None:

0 commit comments

Comments
 (0)