fix(transport): Retry-After honored for 503/504/408 + jitter applied to Retry-After + success-body size cap#368
Conversation
Three MEDIUM-severity audit findings on the HTTP retry/response path collapse into one surgical change because they share two pieces of machinery — Retry-After parsing and the response-body guard pattern. #322: RFC 7231 §7.1.3 lets Retry-After ride on 408 Request Timeout, 503 Service Unavailable, and 504 Gateway Timeout, not just 429. The existing parser (battle-tested by #267 — handles delta-seconds, HTTP-date, NaN/inf, negative deltas) lived inside the 429 branch and was unreachable from any other status. Extracted to `_parse_retry_after(response)`, called from every retryable error branch. `retry_after: float | None` lifted to `KalshiError` so the retry loop reads it via plain attribute access regardless of subclass; `KalshiRateLimitError`'s own `__init__` override is now redundant and removed (the base accepts the same keyword). #321: Full Jitter (`_compute_backoff`) was bypassed on the Retry-After path — the one code path most likely to produce synchronized clients in the first place. A fleet of N processes all hitting the same `Retry-After: 2` would sleep an identical 2.0s and re-stampede on the same tick. Now: floor = `min(retry_after, retry_max_delay)`, plus a Full-Jitter draw on top, capped at `retry_max_delay`. The server's hint stays a hard lower bound; phase-lock breaks across the fleet; the cap still bounds worst-case sleep. The existing `Retry-After: 0` test is updated to assert the floored-and-jittered range; the "not falsy-dropped" property it pinned is preserved. #323: The 16 KB `MAX_ERROR_BODY_BYTES` guard from #203 only fires inside `_map_error`. Every 2xx body went through `response.json()` (or `rest_json_loads(response.content)`) with no size check. A compromised reverse proxy, a hijacked DNS entry, or a backend regression streaming an oversize list-orders payload would materialise the entire body into a Python object graph (5-10x inflation over wire size) before parse completes — OOMing serverless functions and stalling trading. `_enforce_response_body_cap` does a Content-Length pre-check + `len(response.content)` post-buffer check, capped at 64 MiB (well above the largest legitimate Kalshi list endpoint). Mirrored on both `SyncResource._load_json` and `AsyncResource._load_json`. Closes #321, #322, #323
Code Review — PR #368OverviewThree well-scoped correctness fixes on the retry/response path: Retry-After generalized to all retryable statuses (#322), Full Jitter applied on top of server hints to prevent thundering herd (#321), and a 64 MiB size cap mirrored onto the success path (#323). The logic is sound and the test coverage is intentional. A handful of issues worth addressing below. Bugs / Correctness1. if len(response.content) > MAX_RESPONSE_BODY_BYTES:
raise KalshiError(
f"Response body is {len(response.content)} bytes, ..."
body_len = len(response.content)
if body_len > MAX_RESPONSE_BODY_BYTES:
raise KalshiError(f"Response body is {body_len} bytes, ...")2. Redundant tuple element in if status in (408, 429, 504) or status >= 500:
CLAUDE.md Convention Violations3. Multi-paragraph docstrings and block comments CLAUDE.md is explicit: "Never write multi-paragraph docstrings or multi-line comment blocks — one short line max." This PR adds:
All three should be collapsed to a single summary line. The detail belongs in the PR description (where it already is). Test Coverage Gaps4. No async counterparts for the three new sync tests
5. Loop-based test is fragile
Behavioral Change Worth Flagging6. Pre-PR: The intent (avoiding synchronized re-stampede) is correct, and the tests are updated to match. But any caller relying on "the transport never sleeps on Minor
SummaryThe core logic — extracting |
…ests for retry hardening
|
Round-2 review addressed in 37ec046: Addressed:
Verified locally: ruff + mypy clean; Deferred: none — all actionable items resolved. |
Code Review — PR #368Overall: Three well-scoped correctness fixes. The design decisions are sound, tests are thorough, and the Blocking
assert len(items) == total, f"Expected {total} items, got {len(items)} (cap leaked?)"
class TestIssue323SuccessBodyCap: # ← only ONE blank line abovePEP 8 / ruff E302 requires two blank lines before a top-level class definition. CI will reject this. Correctness
if status in (408, 429, 504) or status >= 500:504 appears in both the tuple and satisfies Jitter math — verified against the test fixtures:
Minor Issues
The constant's docstring says a response could OOM "before the parse completes" and refers to a Content-Length "pre-check". In practice, Suggestion: replace "before httpx finishes reading" with "before JSON parse materialises the object graph".
raise KalshiError(
f"Response body is {body_len} bytes, exceeds max_response_bytes=…",
status_code=response.status_code, # e.g. 200
)A Memory-intensive unit test
What's good
Summary: Fix the missing blank line (E302) and optionally address the |
…rom body-cap KalshiError
|
Round-2 review addressed in 28c0acb: E302 blank line; KalshiError status_code dropped on body-cap (client-side enforcement, not server-reported). Deferring: docstring framing nit ("pre-buffer" → "pre-parse", cosmetic); @pytest.mark.slow on 64 MiB test (would lose coverage). |
Code Review — PR #368Three well-scoped correctness fixes around the retry/response path. The overall approach is sound and the PR description is thorough. Below is a mix of genuine issues, minor nits, and confirmations of what's done well. Overview
Medium Issues1.
The Content-Length pre-check covers the most common attack surface (a malicious proxy lying about the body size before sending it), but the post-buffer check for chunked responses is theater for the actual OOM scenario. A comment acknowledging this limitation would set correct expectations: # For chunked responses (no Content-Length), httpx has already buffered the body
# by the time we reach this check. This guards against JSON parse amplification
# (5-10x inflation) but not against the initial buffer allocation.
body_len = len(response.content)Or, if true streaming protection is desired in the future, 2. Every other # Current — status_code is None
raise KalshiError(
f"Response body is {body_len} bytes, exceeds max_response_bytes={MAX_RESPONSE_BODY_BYTES}",
)
# Better — include the actual status code for consistency
raise KalshiError(
f"Response body is {body_len} bytes, exceeds max_response_bytes={MAX_RESPONSE_BODY_BYTES}",
status_code=response.status_code,
)Same applies to the Content-Length pre-check branch. Minor Issues3. Redundant membership check: 504 is in the tuple and covered by if status in (408, 429, 504) or status >= 500:504 is caught by both arms. Not a bug, but noisy — either drop 504 from the tuple or reorder to make intent clearer: if status == 429 or status in (408, 504) or status >= 500:This mirrors the inner if-chain and reads more directly. 4. The PR correctly updates the test comment, but 5. Jitter is effectively zero when floor = min(error.retry_after, self._config.retry_max_delay)
delay = min(
floor + _compute_backoff(attempt, self._config),
self._config.retry_max_delay,
)When What's Done Well
SummaryTwo actionable items before merge:
The rest are low-priority nits. The core logic is correct and the test coverage is solid. |
Summary
Three MEDIUM-severity correctness fixes around the HTTP retry/response path. Retry-After is now honored for every retryable status RFC 7231 §7.1.3 covers (408/503/504/other 5xx, not just 429); Full Jitter is applied on top of the server's Retry-After hint so a synchronized client fleet no longer re-stampedes on the same tick; and the success-path response body now carries the same 16 KB-style size guard
_map_erroralready had on the error path — capped at 64 MiB to bound memory beforeresponse.json()materialises the body into a Python dict graph.Issues closed
retry_max_delay._parse_retry_afterextracted from the 429 branch and applied to 408/503/504/other 5xx;retry_afterlifted to theKalshiErrorbase so the retry loop reads it generically._enforce_response_body_cap(Content-Length pre-check +len(response.content)post-buffer check) added to bothSyncResource._load_jsonandAsyncResource._load_json.Behavioral changes
KalshiRateLimitError.__init__no longer overrides the base;retry_afteris now aKalshiErrorattribute. Existing call sites (KalshiRateLimitError(message, status_code, retry_after=...)) still work — the keyword signature is preserved.test_429_retry_after_zero_is_honored_end_to_endassertionsleeps == [0.0]is updated to[0, retry_base_delay]since the floor is now jittered. The "zero is honored" property (not falsy-dropped) is preserved.KalshiServerErrorandKalshiTimeoutErrorraised from_map_errorcarry the parsedretry_afterwhen the response advertised one. Callers who already type-narrow to those classes can now read.retry_afterto compute their own backoff.Tests
tests/test_base_helpers.py::TestIssue323SuccessBodyCap::test_issue_323_success_body_capped— Content-Length-advertised oversize, chunked actual-oversize, normal-size pass-through, end-to-endSyncResource._getrejection, and malformed Content-Length fall-through.tests/test_client.py::TestSyncTransportRetry::test_issue_321_retry_after_with_jitter— pinnedrandom.uniformto verifyfloor + jittermath (0.03 + 0.005 = 0.035).tests/test_client.py::TestSyncTransportRetry::test_issue_321_retry_after_jitter_capped_at_retry_max_delay—min(floor + jitter, retry_max_delay)ceiling.tests/test_client.py::TestSyncTransportRetry::test_issue_322_retry_after_honored_on_5xx_and_408— 408/503/504 each map to the right typed exception AND sleep the server-hinted floor end-to-end (zero jitter forced for the assertion).tests/test_client.py::TestSyncTransportRetry::test_issue_322_retry_after_attached_to_kalshi_error_base—retry_afteris readable via base-class attribute access (no isinstance narrowing).Source
Round-3 independent audit closure plan, wave W2.