Skip to content

Retry-After path bypasses jitter — synchronized clients stampede the rate limit #321

Closed

Description

@TexasCoding

Problem

When the Kalshi server rate-limits a fleet of clients (e.g. an HFT desk running N processes that all subscribed to the same WS feed and now retry the same REST endpoint), every process receives the same Retry-After: 2 header and sleeps for an identical 2.0 seconds. They all reconnect on the same tick and re-trigger the throttle — exactly the thundering-herd shape that the surrounding _compute_backoff uses AWS Full Jitter to defeat.

The _compute_backoff docstring explicitly motivates Full Jitter ("spreads colliding retries evenly across the full backoff window, so parallel clients that hit a rate limit at the same time don't bunch up in a narrow sub-window"), but the Retry-After branch disables jitter on the one code path most likely to produce synchronized clients in the first place.

Evidence

  • kalshi/_base_client.py:145-155 — Full Jitter rationale:
def _compute_backoff(attempt: int, config: KalshiConfig) -> float:
    """Exponential backoff with AWS "Full Jitter".

    Spreads colliding retries evenly across the full backoff window, so
    parallel clients that hit a rate limit at the same time don't bunch up
    in a narrow sub-window ...
    """
    capped = min(config.retry_base_delay * (2**attempt), config.retry_max_delay)
    return float(random.uniform(0, capped))
  • kalshi/_base_client.py:389-394 — sync retry loop, no jitter on Retry-After:
if isinstance(error, KalshiRateLimitError) and error.retry_after is not None:
    delay = min(error.retry_after, self._config.retry_max_delay)
else:
    delay = _compute_backoff(attempt, self._config)
  • kalshi/_base_client.py:600-604 — async retry loop has the identical un-jittered branch.

Suggested fix

Keep Retry-After as a hard lower bound (honor the server's hint), then add jitter on top. One concrete shape:

if isinstance(error, KalshiRateLimitError) and error.retry_after is not None:
    floor = min(error.retry_after, self._config.retry_max_delay)
    extra = _compute_backoff(attempt, self._config)
    delay = min(floor + extra, self._config.retry_max_delay)
else:
    delay = _compute_backoff(attempt, self._config)

This preserves the server-requested wait, never sleeps less than Retry-After, never exceeds retry_max_delay, and breaks phase-lock across a client fleet. Mirror on the async path. Document in docs/configuration.md next to the existing retry section.

Source

Round-3 independent audit (reviewer: http_transport).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingperformancePerformance / hot-path concern

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions