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
7 changes: 5 additions & 2 deletions openaq/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from __future__ import annotations

import logging
import math
import os
import platform
import re
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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)
Expand Down
37 changes: 37 additions & 0 deletions tests/unit/test_client.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import http
import os
import platform
from datetime import datetime, timedelta
Expand Down Expand Up @@ -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'"""
Expand Down Expand Up @@ -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:
Expand Down
Loading