diff --git a/kalshi/_base_client.py b/kalshi/_base_client.py index 38b254a9..57b91211 100644 --- a/kalshi/_base_client.py +++ b/kalshi/_base_client.py @@ -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") @@ -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) @@ -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)", @@ -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)", diff --git a/tests/test_client.py b/tests/test_client.py index 17982d07..b19d7fed 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -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"}} @@ -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.""" @@ -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