|
9 | 9 | import ratelimit |
10 | 10 |
|
11 | 11 | # DocumentCloud |
| 12 | +from documentcloud import DocumentCloud |
12 | 13 | from documentcloud.constants import RATE_LIMIT |
13 | 14 | from documentcloud.exceptions import APIError, CredentialsFailedError |
14 | 15 |
|
@@ -111,3 +112,77 @@ def test_expired_refresh_token(short_client, record_mode): |
111 | 112 | assert short_client.users.get("me") |
112 | 113 | # check the refresh token was updated |
113 | 114 | 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