Skip to content

Commit 14f7324

Browse files
vdavezclaude
andcommitted
Add rate limit handling: parse 429 response body and expose rate limit headers
TangoRateLimitError now exposes wait_in_seconds, detail, and limit_type properties from the API's 429 response. TangoClient parses X-RateLimit-* headers on every response and exposes them via the rate_limit_info property. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 19d9f82 commit 14f7324

5 files changed

Lines changed: 165 additions & 3 deletions

File tree

tango/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from .models import (
1212
GsaElibraryContract,
1313
PaginatedResponse,
14+
RateLimitInfo,
1415
SearchFilters,
1516
ShapeConfig,
1617
WebhookEndpoint,
@@ -35,6 +36,7 @@
3536
"TangoNotFoundError",
3637
"TangoValidationError",
3738
"TangoRateLimitError",
39+
"RateLimitInfo",
3840
"GsaElibraryContract",
3941
"PaginatedResponse",
4042
"SearchFilters",

tango/client.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
Organization,
3333
PaginatedResponse,
3434
Protest,
35+
RateLimitInfo,
3536
SearchFilters,
3637
ShapeConfig,
3738
Subaward,
@@ -77,6 +78,7 @@ def __init__(
7778
headers["X-API-KEY"] = self.api_key
7879

7980
self.client = httpx.Client(headers=headers, timeout=30.0)
81+
self._last_rate_limit_info: RateLimitInfo | None = None
8082

8183
# Use hardcoded sensible defaults
8284
cache_size = 100
@@ -98,6 +100,34 @@ def __init__(
98100
# Core HTTP Request Utilities
99101
# ============================================================================
100102

103+
@property
104+
def rate_limit_info(self) -> RateLimitInfo | None:
105+
"""Rate limit info from the most recent API response."""
106+
return self._last_rate_limit_info
107+
108+
@staticmethod
109+
def _parse_rate_limit_headers(headers: httpx.Headers) -> RateLimitInfo:
110+
"""Extract rate limit info from response headers."""
111+
def _int_or_none(val: str | None) -> int | None:
112+
if val is None:
113+
return None
114+
try:
115+
return int(val)
116+
except (ValueError, TypeError):
117+
return None
118+
119+
return RateLimitInfo(
120+
limit=_int_or_none(headers.get("X-RateLimit-Limit")),
121+
remaining=_int_or_none(headers.get("X-RateLimit-Remaining")),
122+
reset=_int_or_none(headers.get("X-RateLimit-Reset")),
123+
daily_limit=_int_or_none(headers.get("X-RateLimit-Daily-Limit")),
124+
daily_remaining=_int_or_none(headers.get("X-RateLimit-Daily-Remaining")),
125+
daily_reset=_int_or_none(headers.get("X-RateLimit-Daily-Reset")),
126+
burst_limit=_int_or_none(headers.get("X-RateLimit-Burst-Limit")),
127+
burst_remaining=_int_or_none(headers.get("X-RateLimit-Burst-Remaining")),
128+
burst_reset=_int_or_none(headers.get("X-RateLimit-Burst-Reset")),
129+
)
130+
101131
def _request(
102132
self,
103133
method: str,
@@ -110,6 +140,7 @@ def _request(
110140

111141
try:
112142
response = self.client.request(method=method, url=url, params=params, json=json_data)
143+
self._last_rate_limit_info = self._parse_rate_limit_headers(response.headers)
113144

114145
if response.status_code == 401:
115146
raise TangoAuthError(
@@ -136,7 +167,9 @@ def _request(
136167
error_data,
137168
)
138169
elif response.status_code == 429:
139-
raise TangoRateLimitError("Rate limit exceeded", response.status_code)
170+
error_data = response.json() if response.content else {}
171+
detail = error_data.get("detail", "Rate limit exceeded")
172+
raise TangoRateLimitError(detail, response.status_code, error_data)
140173
elif not response.is_success:
141174
raise TangoAPIError(
142175
f"API request failed with status {response.status_code}", response.status_code

tango/exceptions.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,34 @@ class TangoValidationError(TangoAPIError):
3939
class TangoRateLimitError(TangoAPIError):
4040
"""Rate limit exceeded error"""
4141

42-
pass
42+
@property
43+
def wait_in_seconds(self) -> int | None:
44+
"""Seconds to wait before retrying, from API response."""
45+
val = self.response_data.get("wait_in_seconds")
46+
if val is not None:
47+
try:
48+
return int(val)
49+
except (ValueError, TypeError):
50+
return None
51+
return None
52+
53+
@property
54+
def detail(self) -> str | None:
55+
"""Human-readable detail from API response."""
56+
return self.response_data.get("detail")
57+
58+
@property
59+
def limit_type(self) -> str | None:
60+
"""Which limit was hit: 'burst' or 'daily', parsed from detail."""
61+
d = self.detail
62+
if not d:
63+
return None
64+
lower = d.lower()
65+
if "burst" in lower or "minute" in lower:
66+
return "burst"
67+
if "daily" in lower or "day" in lower:
68+
return "daily"
69+
return None
4370

4471

4572
class ShapeError(TangoAPIError):

tango/models.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,21 @@
2323
# ============================================================================
2424

2525

26+
@dataclass
27+
class RateLimitInfo:
28+
"""Rate limit information from API response headers."""
29+
30+
limit: int | None = None
31+
remaining: int | None = None
32+
reset: int | None = None
33+
daily_limit: int | None = None
34+
daily_remaining: int | None = None
35+
daily_reset: int | None = None
36+
burst_limit: int | None = None
37+
burst_remaining: int | None = None
38+
burst_reset: int | None = None
39+
40+
2641
@dataclass
2742
class SearchFilters:
2843
"""Search filter parameters for contract search

tests/test_client.py

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1065,10 +1065,16 @@ def test_400_validation_error_no_content(self, mock_request):
10651065

10661066
@patch("tango.client.httpx.Client.request")
10671067
def test_429_rate_limit_error(self, mock_request):
1068-
"""Test 429 Rate Limit raises TangoRateLimitError"""
1068+
"""Test 429 Rate Limit raises TangoRateLimitError with parsed body"""
10691069
mock_response = Mock()
10701070
mock_response.is_success = False
10711071
mock_response.status_code = 429
1072+
mock_response.content = b'{"detail": "Rate limit exceeded for burst. Please try again in 45 seconds.", "wait_in_seconds": 45}'
1073+
mock_response.json.return_value = {
1074+
"detail": "Rate limit exceeded for burst. Please try again in 45 seconds.",
1075+
"wait_in_seconds": 45,
1076+
}
1077+
mock_response.headers = {}
10721078
mock_request.return_value = mock_response
10731079

10741080
client = TangoClient(api_key="test-key")
@@ -1077,6 +1083,85 @@ def test_429_rate_limit_error(self, mock_request):
10771083
client.list_agencies()
10781084

10791085
assert exc_info.value.status_code == 429
1086+
assert exc_info.value.wait_in_seconds == 45
1087+
assert "burst" in exc_info.value.detail
1088+
assert exc_info.value.limit_type == "burst"
1089+
1090+
@patch("tango.client.httpx.Client.request")
1091+
def test_429_daily_limit_error(self, mock_request):
1092+
"""Test 429 for daily limit includes correct limit_type"""
1093+
mock_response = Mock()
1094+
mock_response.is_success = False
1095+
mock_response.status_code = 429
1096+
mock_response.content = b'{"detail": "Rate limit exceeded for daily. Please try again in 3600 seconds.", "wait_in_seconds": 3600}'
1097+
mock_response.json.return_value = {
1098+
"detail": "Rate limit exceeded for daily. Please try again in 3600 seconds.",
1099+
"wait_in_seconds": 3600,
1100+
}
1101+
mock_response.headers = {}
1102+
mock_request.return_value = mock_response
1103+
1104+
client = TangoClient(api_key="test-key")
1105+
1106+
with pytest.raises(TangoRateLimitError) as exc_info:
1107+
client.list_agencies()
1108+
1109+
assert exc_info.value.limit_type == "daily"
1110+
assert exc_info.value.wait_in_seconds == 3600
1111+
1112+
@patch("tango.client.httpx.Client.request")
1113+
def test_429_empty_body(self, mock_request):
1114+
"""Test 429 with no content body still works"""
1115+
mock_response = Mock()
1116+
mock_response.is_success = False
1117+
mock_response.status_code = 429
1118+
mock_response.content = None
1119+
mock_response.headers = {}
1120+
mock_request.return_value = mock_response
1121+
1122+
client = TangoClient(api_key="test-key")
1123+
1124+
with pytest.raises(TangoRateLimitError) as exc_info:
1125+
client.list_agencies()
1126+
1127+
assert exc_info.value.status_code == 429
1128+
assert exc_info.value.wait_in_seconds is None
1129+
assert exc_info.value.limit_type is None
1130+
1131+
@patch("tango.client.httpx.Client.request")
1132+
def test_rate_limit_headers_parsed(self, mock_request):
1133+
"""Test rate limit headers are parsed from successful responses"""
1134+
mock_response = Mock()
1135+
mock_response.is_success = True
1136+
mock_response.status_code = 200
1137+
mock_response.content = b'{"results": []}'
1138+
mock_response.json.return_value = {"results": []}
1139+
mock_response.headers = {
1140+
"X-RateLimit-Limit": "100",
1141+
"X-RateLimit-Remaining": "95",
1142+
"X-RateLimit-Reset": "45",
1143+
"X-RateLimit-Daily-Limit": "2400",
1144+
"X-RateLimit-Daily-Remaining": "2350",
1145+
"X-RateLimit-Daily-Reset": "86400",
1146+
"X-RateLimit-Burst-Limit": "100",
1147+
"X-RateLimit-Burst-Remaining": "95",
1148+
"X-RateLimit-Burst-Reset": "45",
1149+
}
1150+
mock_request.return_value = mock_response
1151+
1152+
client = TangoClient(api_key="test-key")
1153+
assert client.rate_limit_info is None
1154+
1155+
client._request("GET", "/api/agencies/")
1156+
1157+
info = client.rate_limit_info
1158+
assert info is not None
1159+
assert info.limit == 100
1160+
assert info.remaining == 95
1161+
assert info.reset == 45
1162+
assert info.daily_limit == 2400
1163+
assert info.daily_remaining == 2350
1164+
assert info.burst_remaining == 95
10801165

10811166
@patch("tango.client.httpx.Client.request")
10821167
def test_500_server_error(self, mock_request):

0 commit comments

Comments
 (0)