Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 43 additions & 32 deletions kalshi/_base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,26 +49,30 @@ def _map_error(response: httpx.Response) -> KalshiError:
status = response.status_code

# #203: cap body buffering. If the server advertises an oversized body,
# short-circuit before httpx materialises it into memory.
# short-circuit before httpx materialises it into memory. We still fall
# through to status dispatch so the typed exception class is preserved
# (#252) — only the message changes.
content_length = response.headers.get("content-length")
suppressed: bool = False
if content_length:
try:
if int(content_length) > MAX_ERROR_BODY_BYTES:
return KalshiError(
message=f"HTTP {status} (body {content_length} bytes, suppressed)",
status_code=status,
)
suppressed = True
except ValueError:
pass # malformed Content-Length — fall through to normal parse

try:
body = response.json()
except Exception:
body: dict[str, Any]
if suppressed:
body = {}

raw_message = body.get("message") or body.get("error") or response.text or f"HTTP {status}"
# Truncate to bound log-line and exception-message size.
message = str(raw_message)[:MAX_ERROR_MESSAGE_CHARS]
message = f"HTTP {status} (body {content_length} bytes, suppressed)"
else:
try:
body = response.json()
except Exception:
body = {}
raw_message = body.get("message") or body.get("error") or response.text or f"HTTP {status}"
# Truncate to bound log-line and exception-message size.
message = str(raw_message)[:MAX_ERROR_MESSAGE_CHARS]

if status in (400, 422):
details = body.get("details") or body.get("errors")
Expand Down Expand Up @@ -116,6 +120,12 @@ def _map_error(response: httpx.Response) -> KalshiError:
return KalshiRateLimitError(
message=message, status_code=status, retry_after=retry_after_val
)
# #251: 408 Request Timeout and 504 Gateway Timeout carry the same
# "may have committed" semantic as a transport-level timeout. Route them
# to KalshiTimeoutError so callers can branch on it (e.g., reconcile via
# client_order_id before retrying an order create).
if status in (408, 504):
return KalshiTimeoutError(message=message, status_code=status)
if status >= 500:
return KalshiServerError(message=message, status_code=status)

Expand Down Expand Up @@ -203,20 +213,20 @@ def request(
# #193: wall-clock budget across the whole request including retries.
start = time.monotonic()
total_timeout = self._config.total_timeout
config_extra = self._config.extra_headers or {}
per_call_extra = extra_headers or {}
body_headers = headers or {}
# #262: header layers config_extra/per_call_extra/body_headers are
# loop-invariant; only auth_headers changes per attempt (fresh
# timestamp). Merge the invariant base once outside the loop.
# Order matters: config defaults < per-request overrides
# < body-helper headers < signed auth.
base_headers: dict[str, str] = {
**(self._config.extra_headers or {}),
**(extra_headers or {}),
**(headers or {}),
}

for attempt in range(self._config.max_retries + 1):
auth_headers = self._auth.sign_request(method.upper(), sign_path) if self._auth else {}
# Order matters: config defaults < per-request overrides
# < body-helper headers < signed auth.
merged_headers = {
**config_extra,
**per_call_extra,
**body_headers,
**auth_headers,
}
merged_headers = {**base_headers, **auth_headers}

logger.debug(
"Request: %s %s (attempt %d/%d)",
Expand Down Expand Up @@ -403,21 +413,22 @@ async def request(
# #193: wall-clock budget across the whole request including retries.
start = time.monotonic()
total_timeout = self._config.total_timeout
config_extra = self._config.extra_headers or {}
per_call_extra = extra_headers or {}
body_headers = headers or {}
# #262: see SyncTransport.request — hoist invariant header layers
# out of the retry loop; only auth_headers changes per attempt.
# Order matters: config defaults < per-request overrides
# < body-helper headers < signed auth.
base_headers: dict[str, str] = {
**(self._config.extra_headers or {}),
**(extra_headers or {}),
**(headers or {}),
}

for attempt in range(self._config.max_retries + 1):
if self._auth:
auth_headers = await self._auth.sign_request_async(method.upper(), sign_path)
else:
auth_headers = {}
merged_headers = {
**config_extra,
**per_call_extra,
**body_headers,
**auth_headers,
}
merged_headers = {**base_headers, **auth_headers}

logger.debug(
"Async request: %s %s (attempt %d/%d)",
Expand Down
55 changes: 54 additions & 1 deletion tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,30 @@ def test_503_server_error(self) -> None:
err = _map_error(resp)
assert isinstance(err, KalshiServerError)

def test_408_request_timeout_maps_to_timeout(self) -> None:
# #251: 408 carries "may have committed" semantic — route to
# KalshiTimeoutError, not generic KalshiError.
resp = httpx.Response(408, json={"message": "request timeout"})
err = _map_error(resp)
assert isinstance(err, KalshiTimeoutError)
assert err.status_code == 408

def test_504_gateway_timeout_maps_to_timeout(self) -> None:
# #251: 504 carries same semantic as transport timeout — must route to
# KalshiTimeoutError, not KalshiServerError.
resp = httpx.Response(504, json={"message": "gateway timeout"})
err = _map_error(resp)
assert isinstance(err, KalshiTimeoutError)
assert err.status_code == 504

def test_500_still_server_error(self) -> None:
# #251: regression — 500/502/503 must continue to route to KalshiServerError.
for status in (500, 502, 503):
resp = httpx.Response(status, json={"message": "boom"})
err = _map_error(resp)
assert isinstance(err, KalshiServerError), f"status={status}"
assert not isinstance(err, KalshiTimeoutError), f"status={status}"

def test_validation_error_with_details(self) -> None:
resp = httpx.Response(
400, json={"message": "validation failed", "details": {"ticker": "required"}}
Expand Down Expand Up @@ -987,7 +1011,6 @@ def test_from_env_typed_kwargs_forwarded(self, monkeypatch: pytest.MonkeyPatch)
assert client._config.max_retries == 11
client.close()


class TestWidenedRetrySet:
"""#192: Cloudflare 5xx (520-524) + 408 are retryable on safe methods only."""

Expand Down Expand Up @@ -1062,6 +1085,36 @@ def test_oversized_error_body_truncated_by_content_length_header(self) -> None:
err = _map_error(resp)
assert "suppressed" in str(err)
assert "50000" in str(err)
# #252: typed exception class preserved even when body is suppressed.
assert isinstance(err, KalshiServerError)

def test_oversized_body_429_preserves_rate_limit_with_retry_after(self) -> None:
# #252: a hostile 429 with verbose body must still surface as
# KalshiRateLimitError and the Retry-After header (read separately
# from the body) must still populate.
resp = httpx.Response(
429,
headers={"content-length": "50000", "Retry-After": "2.5"},
text="x",
)
err = _map_error(resp)
assert isinstance(err, KalshiRateLimitError)
assert err.retry_after == 2.5
assert "suppressed" in str(err)

def test_oversized_body_401_preserves_auth_error(self) -> None:
# #252
resp = httpx.Response(401, headers={"content-length": "20000"}, text="x")
err = _map_error(resp)
assert isinstance(err, KalshiAuthError)
assert "suppressed" in str(err)

def test_oversized_body_409_preserves_conflict_error(self) -> None:
# #252
resp = httpx.Response(409, headers={"content-length": "20000"}, text="x")
err = _map_error(resp)
assert isinstance(err, KalshiConflictError)
assert "suppressed" in str(err)

def test_error_message_truncated_to_1024_chars(self) -> None:
long_msg = "A" * 5000
Expand Down
Loading