Skip to content

Commit f35c68f

Browse files
authored
feat: add rate limit statistics (#343)
### Description - Add `Statistics` for gather HTTP rate limit errors - Linked Issues is in the sdk, but the implementation should be in the client ### Issues - apify/apify-sdk-python#318 ### Testing - Add tests for `Statistics`
1 parent 184c8e2 commit f35c68f

File tree

4 files changed

+91
-0
lines changed

4 files changed

+91
-0
lines changed

src/apify_client/_http_client.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from apify_client._errors import ApifyApiError, InvalidResponseBodyError, is_retryable_error
1616
from apify_client._logging import log_context, logger_name
17+
from apify_client._statistics import Statistics
1718
from apify_client._utils import retry_with_exp_backoff, retry_with_exp_backoff_async
1819

1920
if TYPE_CHECKING:
@@ -35,6 +36,7 @@ def __init__(
3536
max_retries: int = 8,
3637
min_delay_between_retries_millis: int = 500,
3738
timeout_secs: int = 360,
39+
stats: Statistics | None = None,
3840
) -> None:
3941
self.max_retries = max_retries
4042
self.min_delay_between_retries_millis = min_delay_between_retries_millis
@@ -59,6 +61,8 @@ def __init__(
5961
self.httpx_client = httpx.Client(headers=headers, follow_redirects=True, timeout=timeout_secs)
6062
self.httpx_async_client = httpx.AsyncClient(headers=headers, follow_redirects=True, timeout=timeout_secs)
6163

64+
self.stats = stats or Statistics()
65+
6266
@staticmethod
6367
def _maybe_parse_response(response: httpx.Response) -> Any:
6468
if response.status_code == HTTPStatus.NO_CONTENT:
@@ -143,6 +147,8 @@ def call(
143147
log_context.method.set(method)
144148
log_context.url.set(url)
145149

150+
self.stats.calls += 1
151+
146152
if stream and parse_response:
147153
raise ValueError('Cannot stream response and parse it at the same time!')
148154

@@ -153,6 +159,9 @@ def call(
153159
def _make_request(stop_retrying: Callable, attempt: int) -> httpx.Response:
154160
log_context.attempt.set(attempt)
155161
logger.debug('Sending request')
162+
163+
self.stats.requests += 1
164+
156165
try:
157166
request = httpx_client.build_request(
158167
method=method,
@@ -177,6 +186,9 @@ def _make_request(stop_retrying: Callable, attempt: int) -> httpx.Response:
177186

178187
return response
179188

189+
if response.status_code == HTTPStatus.TOO_MANY_REQUESTS:
190+
self.stats.add_rate_limit_error(attempt)
191+
180192
except Exception as e:
181193
logger.debug('Request threw exception', exc_info=e)
182194
if not is_retryable_error(e):
@@ -217,6 +229,8 @@ async def call(
217229
log_context.method.set(method)
218230
log_context.url.set(url)
219231

232+
self.stats.calls += 1
233+
220234
if stream and parse_response:
221235
raise ValueError('Cannot stream response and parse it at the same time!')
222236

@@ -251,6 +265,9 @@ async def _make_request(stop_retrying: Callable, attempt: int) -> httpx.Response
251265

252266
return response
253267

268+
if response.status_code == HTTPStatus.TOO_MANY_REQUESTS:
269+
self.stats.add_rate_limit_error(attempt)
270+
254271
except Exception as e:
255272
logger.debug('Request threw exception', exc_info=e)
256273
if not is_retryable_error(e):

src/apify_client/_statistics.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from collections import defaultdict
2+
from dataclasses import dataclass, field
3+
4+
5+
@dataclass
6+
class Statistics:
7+
"""Statistics about API client usage and rate limit errors."""
8+
9+
calls: int = 0
10+
"""Total number of API method calls made by the client."""
11+
12+
requests: int = 0
13+
"""Total number of HTTP requests sent, including retries."""
14+
15+
rate_limit_errors: defaultdict[int, int] = field(default_factory=lambda: defaultdict(int))
16+
"""List tracking which retry attempts encountered rate limit (429) errors."""
17+
18+
def add_rate_limit_error(self, attempt: int) -> None:
19+
"""Add rate limit error for specific attempt.
20+
21+
Args:
22+
attempt: The attempt number (1-based indexing).
23+
"""
24+
if attempt < 1:
25+
raise ValueError('Attempt must be greater than 0')
26+
27+
self.rate_limit_errors[attempt - 1] += 1

src/apify_client/client.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from apify_shared.utils import ignore_docs
44

55
from apify_client._http_client import HTTPClient, HTTPClientAsync
6+
from apify_client._statistics import Statistics
67
from apify_client.clients import (
78
ActorClient,
89
ActorClientAsync,
@@ -126,11 +127,13 @@ def __init__(
126127
timeout_secs=timeout_secs,
127128
)
128129

130+
self.stats = Statistics()
129131
self.http_client = HTTPClient(
130132
token=token,
131133
max_retries=self.max_retries,
132134
min_delay_between_retries_millis=self.min_delay_between_retries_millis,
133135
timeout_secs=self.timeout_secs,
136+
stats=self.stats,
134137
)
135138

136139
def actor(self, actor_id: str) -> ActorClient:

tests/unit/test_statistics.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import pytest
2+
3+
from apify_client._statistics import Statistics
4+
5+
6+
@pytest.mark.parametrize(
7+
('attempts', 'expected_errors'),
8+
[
9+
pytest.param([1], {0: 1}, id='single error'),
10+
pytest.param([1, 5], {0: 1, 4: 1}, id='two single errors'),
11+
pytest.param([5, 1], {0: 1, 4: 1}, id='two single errors reversed'),
12+
pytest.param([3, 5, 1], {0: 1, 2: 1, 4: 1}, id='three single errors'),
13+
pytest.param([1, 5, 3], {0: 1, 2: 1, 4: 1}, id='three single errors reordered'),
14+
pytest.param([2, 1, 2, 1, 5, 2, 1], {0: 3, 1: 3, 4: 1}, id='multiple errors per attempt'),
15+
],
16+
)
17+
def test_add_rate_limit_error(attempts: list[int], expected_errors: list[int]) -> None:
18+
"""Test that add_rate_limit_error correctly tracks errors for different attempt sequences."""
19+
stats = Statistics()
20+
for attempt in attempts:
21+
stats.add_rate_limit_error(attempt)
22+
assert stats.rate_limit_errors == expected_errors
23+
24+
25+
def test_add_rate_limit_error_invalid_attempt() -> None:
26+
"""Test that add_rate_limit_error raises ValueError for invalid attempt."""
27+
stats = Statistics()
28+
with pytest.raises(ValueError, match='Attempt must be greater than 0'):
29+
stats.add_rate_limit_error(0)
30+
31+
32+
def test_statistics_initial_state() -> None:
33+
"""Test initial state of Statistics instance."""
34+
stats = Statistics()
35+
assert stats.calls == 0
36+
assert stats.requests == 0
37+
assert stats.rate_limit_errors == {}
38+
39+
40+
def test_add_rate_limit_error_type_validation() -> None:
41+
"""Test type validation in add_rate_limit_error."""
42+
stats = Statistics()
43+
with pytest.raises(TypeError):
44+
stats.add_rate_limit_error('1') # type: ignore[arg-type]

0 commit comments

Comments
 (0)