From 82b5c1bd4ae17d06dea140f0b0e96e13777b13ba Mon Sep 17 00:00:00 2001 From: Russ Biggs Date: Mon, 29 Jun 2026 16:04:35 -0600 Subject: [PATCH] fix rate limiter --- openaq/client.py | 7 +++++-- tests/unit/test_client.py | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/openaq/client.py b/openaq/client.py index c355da57..5d304267 100644 --- a/openaq/client.py +++ b/openaq/client.py @@ -7,6 +7,7 @@ from __future__ import annotations import logging +import math import os import platform import re @@ -248,7 +249,9 @@ def base_url(self) -> str: @property def _rate_limit_reset_seconds(self) -> int: """Seconds remaining until the rate limit window resets.""" - return int((self._rate_limit_reset_datetime - datetime.now()).total_seconds()) + return math.ceil( + (self._rate_limit_reset_datetime - datetime.now()).total_seconds() + ) def _is_rate_limited(self) -> bool: """Returns True if the rate limit is exhausted and the reset time has not yet passed.""" @@ -311,7 +314,7 @@ def _wait_for_rate_limit_reset(self) -> None: Returns immediately if the reset time has already passed. """ wait_seconds = self._rate_limit_reset_seconds - if wait_seconds <= 0: + if wait_seconds <= 1: wait_seconds = 1 logger.info("Rate limit hit. Waiting %s seconds for reset.", wait_seconds) time.sleep(wait_seconds) diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 3b6e38d5..858af873 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -1,3 +1,4 @@ +import http import os import platform from datetime import datetime, timedelta @@ -61,6 +62,15 @@ def test__check_api_key(api_key, expected_exception): assert result == api_key +def apply_rate_limit_headers(client, *, at, remaining, ttl): + msg = http.client.HTTPMessage() + msg["x-ratelimit-limit"] = "60" + msg["x-ratelimit-remaining"] = str(remaining) + msg["x-ratelimit-reset"] = str(ttl) + with freeze_time(at): + client._set_rate_limit(Headers(msg)) + + @pytest.fixture def mock_config_file(): mock_toml_content = b"""api-key='e7a3a978e3e018e932d666c481ff33b82b7150c6084c0de175755c5cb763a5c5'""" @@ -480,6 +490,33 @@ def test_raises_value_error_for_base_url_without_netloc(self): _transport=MockTransport(), ) + def test_production_replay_20260628(self, setup): + apply_rate_limit_headers( + self.client, at="2026-06-28 17:55:44.59+00", remaining=1, ttl=11 + ) + with freeze_time("2026-06-28 17:55:50.1+00"): + self.client._check_rate_limit() + + apply_rate_limit_headers( + self.client, at="2026-06-28 17:55:50.1+00", remaining=0, ttl=10 + ) + + with ( + freeze_time("2026-06-28 17:55:45.1+00"), + patch("time.sleep") as mock_sleep, + ): + self.client._check_rate_limit() + + mock_sleep.assert_called_once() + sleep_time = mock_sleep.call_args[0][0] + + assert sleep_time >= 15.0 + + with freeze_time("2026-06-28 17:56:01.000000+00"): + with patch("time.sleep") as mock_sleep_after_reset: + self.client._check_rate_limit() + mock_sleep_after_reset.assert_not_called() + def test_tomllib_conditional_import(): if int(platform.python_version_tuple()[1]) >= 11: