Skip to content

Commit 0c18b54

Browse files
Add sane rate limits,tests for rate limits and fix workflow
1 parent eb44160 commit 0c18b54

5 files changed

Lines changed: 159 additions & 27 deletions

File tree

.github/workflows/main.yml

Lines changed: 6 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
name: Run Pre-recorded Tests
2-
32
on:
43
pull_request:
54
branches:
65
- master
76
push:
87
branches:
98
- master
10-
119
jobs:
1210
run-tests:
1311
runs-on: ubuntu-latest
@@ -17,40 +15,26 @@ jobs:
1715
steps:
1816
- name: Check out code
1917
uses: actions/checkout@v6
20-
2118
- name: Set up Python ${{ matrix.python-version }}
2219
uses: actions/setup-python@v6
2320
with:
2421
python-version: ${{ matrix.python-version }}
25-
2622
- name: Install dependencies
27-
run: |
28-
pip install vcrpy pytest==7.4.2 requests pytest-mock python-documentcloud pytest-xdist pytest-recording python-squarelet
29-
23+
run: pip install -e ".[test]"
3024
- name: Run pre-recorded tests
31-
run: |
32-
make test
33-
working-directory: .
34-
25+
run: make test
3526
pylint-and-black:
3627
runs-on: ubuntu-latest
3728
steps:
3829
- name: Check out code
3930
uses: actions/checkout@v6
40-
4131
- name: Set up Python 3.11
4232
uses: actions/setup-python@v6
4333
with:
4434
python-version: "3.11"
45-
46-
- name: Install dependencies for imports
47-
run: |
48-
pip install python-dateutil requests urllib3 fastjsonschema ratelimit listcrunch pyyaml pytest vcrpy python-squarelet
49-
50-
- name: Install pylint and black
51-
run: |
52-
pip install pylint black
53-
35+
- name: Install dependencies
36+
run: pip install -e ".[dev,test]"
5437
- name: Run pylint and black on ./documentcloud and ./tests
5538
run: |
56-
pylint ./documentcloud ./tests; black --check ./documentcloud ./tests
39+
pylint ./documentcloud ./tests
40+
black --check ./documentcloud ./tests

documentcloud/client.py

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,35 @@
1-
# Import SquareletClient from python-squarelet
21
# Standard Library
32
import logging
3+
import time
44

55
# Third Party
6+
import token_bucket
67
from squarelet import SquareletClient
78

89
# Local
9-
# Local Imports
1010
from .documents import DocumentClient
1111
from .organizations import OrganizationClient
1212
from .projects import ProjectClient
1313
from .users import UserClient
1414

1515
logger = logging.getLogger("documentcloud")
1616

17+
# Per-endpoint rate limits applied on top of the global squarelet limit.
18+
# Format: (method, url_pattern, rate_per_second, capacity)
19+
#
20+
# Endpoint Rate Burst Notes
21+
# -------- ---- ----- -----
22+
# GET documents/search 15/min 50
23+
# POST documents/ 12/min 100 25 docs/bulk call = up to 300 docs/min
24+
# PUT documents/ 12/min 100 25 docs/bulk call = up to 300 docs/min
25+
# GET files/ 15/min 100 PDFs, full text, and other private assets
26+
ENDPOINT_RATE_LIMITS = [
27+
("GET", "documents/search", 15 / 60, 50),
28+
("POST", "documents/", 12 / 60, 100),
29+
("PUT", "documents/", 12 / 60, 100),
30+
("GET", "files/", 15 / 60, 100),
31+
]
32+
1733

1834
class DocumentCloud(SquareletClient):
1935
"""
@@ -51,8 +67,34 @@ def __init__(
5167
else:
5268
logger.addHandler(logging.NullHandler())
5369

70+
# Build per-endpoint token bucket rate limiters
71+
storage = token_bucket.MemoryStorage()
72+
self._endpoint_limiters = [
73+
(
74+
pattern_method,
75+
pattern,
76+
token_bucket.Limiter(rate=rate, capacity=capacity, storage=storage),
77+
f"{pattern_method}:{pattern}",
78+
)
79+
for pattern_method, pattern, rate, capacity in ENDPOINT_RATE_LIMITS
80+
]
81+
5482
# Initialize the sub-clients using SquareletClient
5583
self.documents = DocumentClient(self)
5684
self.projects = ProjectClient(self)
5785
self.users = UserClient(self)
5886
self.organizations = OrganizationClient(self)
87+
88+
def request(self, method, url, raise_error=True, **kwargs):
89+
for pattern_method, pattern, limiter, bucket_key in self._endpoint_limiters:
90+
if pattern_method.upper() == method.upper() and pattern in url:
91+
if not limiter.consume(bucket_key):
92+
logger.warning(
93+
"Rate limit reached for %s %s, throttling...",
94+
method.upper(),
95+
pattern,
96+
)
97+
while not limiter.consume(bucket_key):
98+
time.sleep(0.1)
99+
return super().request(method, url, raise_error=raise_error, **kwargs)
100+
return super().request(method, url, raise_error=raise_error, **kwargs)

documentcloud/documents.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@
77
import logging
88
import os
99
import re
10+
import time
1011
import warnings
1112
from functools import partial
1213
from urllib.parse import urlparse
1314

1415
# Third Party
16+
import token_bucket
1517
from requests.exceptions import RequestException
1618

1719
# Local
@@ -28,6 +30,8 @@
2830

2931
IMAGE_SIZES = ["thumbnail", "small", "normal", "large", "xlarge"]
3032

33+
DEFAULT_USER_AGENT = "python-documentcloud"
34+
3135

3236
class Document(BaseAPIObject):
3337
"""A single DocumentCloud document"""
@@ -164,12 +168,17 @@ def _get_url(self, url, fmt=None):
164168

165169
if base_netloc == url_netloc:
166170
# if the url host is the same as the base api host,
167-
# sent the request with the client in order to include
171+
# send the request with the client in order to include
168172
# authentication credentials
169173
response = self._client.get(url, full_url=True)
170174
else:
171-
response = requests_retry_session().get(
172-
url, headers={"User-Agent": "python-documentcloud2"}
175+
response = self._client.documents.asset_get(
176+
url,
177+
headers={
178+
"User-Agent": self._client.session.headers.get(
179+
"User-Agent", DEFAULT_USER_AGENT
180+
)
181+
},
173182
)
174183
if fmt == "text":
175184
return response.content.decode("utf8")
@@ -246,6 +255,26 @@ class DocumentClient(BaseAPIClient):
246255
api_path = "documents"
247256
resource = Document
248257

258+
def __init__(self, client):
259+
super().__init__(client)
260+
# Rate limit for public document asset fetches (S3-hosted).
261+
# Private document assets go through the API client and are limited there.
262+
# Token bucket: burst of 100, sustained at 15/min (0.25/sec).
263+
storage = token_bucket.MemoryStorage()
264+
self._asset_limiter = token_bucket.Limiter(
265+
rate=15 / 60,
266+
capacity=100,
267+
storage=storage,
268+
)
269+
self._asset_session = requests_retry_session()
270+
271+
def asset_get(self, url, **kwargs):
272+
if not self._asset_limiter.consume("asset"):
273+
logger.warning("Rate limit reached for asset fetch, throttling...")
274+
while not self._asset_limiter.consume("asset"):
275+
time.sleep(0.1)
276+
return self._asset_session.get(url, **kwargs)
277+
249278
def search(self, query, **params):
250279
"""Return documents matching a search query"""
251280

setup.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"pyyaml",
2828
"fastjsonschema",
2929
"python-squarelet",
30+
"token-bucket",
3031
),
3132
extras_require={
3233
"dev": [
@@ -40,6 +41,7 @@
4041
"test": [
4142
"pytest",
4243
"pytest-mock",
44+
"pytest-xdist",
4345
"pytest-recording",
4446
"vcrpy",
4547
],

tests/test_client.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import ratelimit
1010

1111
# DocumentCloud
12+
from documentcloud import DocumentCloud
1213
from documentcloud.constants import RATE_LIMIT
1314
from documentcloud.exceptions import APIError, CredentialsFailedError
1415

@@ -111,3 +112,77 @@ def test_expired_refresh_token(short_client, record_mode):
111112
assert short_client.users.get("me")
112113
# check the refresh token was updated
113114
assert old_refresh_token != short_client.refresh_token
115+
116+
117+
def test_endpoint_rate_limit_burst_exhaustion():
118+
"""Token bucket should block after burst capacity is exhausted"""
119+
client = DocumentCloud()
120+
# Exhaust the search burst (capacity=50)
121+
_pattern_method, _pattern, limiter, bucket_key = client._endpoint_limiters[0]
122+
for _ in range(50):
123+
limiter.consume(bucket_key)
124+
assert not limiter.consume(bucket_key)
125+
126+
127+
def test_endpoint_rate_limit_method_specificity():
128+
"""GET and POST to documents/ should use different limiters"""
129+
client = DocumentCloud()
130+
limiters = {(pm, p): lim for pm, p, lim, _ in client._endpoint_limiters}
131+
assert limiters[("GET", "files/")] is not limiters[("POST", "documents/")]
132+
133+
134+
def test_endpoint_rate_limit_pattern_ordering():
135+
"""documents/search should match before documents/"""
136+
client = DocumentCloud()
137+
url = "documents/search/"
138+
matched = next(
139+
p for pm, p, _, _ in client._endpoint_limiters if pm == "GET" and p in url
140+
)
141+
assert matched == "documents/search"
142+
143+
144+
def test_asset_rate_limit_burst_exhaustion():
145+
"""Asset token bucket should block after burst capacity is exhausted"""
146+
client = DocumentCloud()
147+
limiter = client.documents._asset_limiter
148+
for _ in range(100):
149+
limiter.consume("asset")
150+
assert not limiter.consume("asset")
151+
152+
153+
def test_asset_rate_limit_refills():
154+
"""Asset token bucket should refill over time"""
155+
client = DocumentCloud()
156+
limiter = client.documents._asset_limiter
157+
for _ in range(100):
158+
limiter.consume("asset")
159+
assert not limiter.consume("asset")
160+
time.sleep(5)
161+
assert limiter.consume("asset")
162+
163+
164+
def test_endpoint_rate_limit_buckets_are_independent():
165+
"""Exhausting one endpoint's bucket should not affect another"""
166+
client = DocumentCloud()
167+
limiters = {(pm, p): (lim, bk) for pm, p, lim, bk in client._endpoint_limiters}
168+
search_limiter, search_key = limiters[("GET", "documents/search")]
169+
files_limiter, files_key = limiters[("GET", "files/")]
170+
171+
# Exhaust search bucket
172+
for _ in range(50):
173+
search_limiter.consume(search_key)
174+
assert not search_limiter.consume(search_key)
175+
176+
# Files bucket should still have tokens
177+
assert files_limiter.consume(files_key)
178+
179+
180+
def test_endpoint_rate_limit_no_match_for_unrecognized_url():
181+
"""Unrecognized URLs should not match any endpoint limiter"""
182+
client = DocumentCloud()
183+
url = "users/me/"
184+
matched = next(
185+
(p for pm, p, _, _ in client._endpoint_limiters if p in url),
186+
None,
187+
)
188+
assert matched is None

0 commit comments

Comments
 (0)