From 14f73246a8f4e999b7a7c005d079ab8f3dd045e9 Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Sat, 21 Mar 2026 05:12:55 -0500 Subject: [PATCH 1/2] 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) --- tango/__init__.py | 2 + tango/client.py | 35 +++++++++++++++++- tango/exceptions.py | 29 ++++++++++++++- tango/models.py | 15 ++++++++ tests/test_client.py | 87 +++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 165 insertions(+), 3 deletions(-) diff --git a/tango/__init__.py b/tango/__init__.py index f7de743..72b6b13 100644 --- a/tango/__init__.py +++ b/tango/__init__.py @@ -11,6 +11,7 @@ from .models import ( GsaElibraryContract, PaginatedResponse, + RateLimitInfo, SearchFilters, ShapeConfig, WebhookEndpoint, @@ -35,6 +36,7 @@ "TangoNotFoundError", "TangoValidationError", "TangoRateLimitError", + "RateLimitInfo", "GsaElibraryContract", "PaginatedResponse", "SearchFilters", diff --git a/tango/client.py b/tango/client.py index f5b4ae2..15e3f44 100644 --- a/tango/client.py +++ b/tango/client.py @@ -32,6 +32,7 @@ Organization, PaginatedResponse, Protest, + RateLimitInfo, SearchFilters, ShapeConfig, Subaward, @@ -77,6 +78,7 @@ def __init__( headers["X-API-KEY"] = self.api_key self.client = httpx.Client(headers=headers, timeout=30.0) + self._last_rate_limit_info: RateLimitInfo | None = None # Use hardcoded sensible defaults cache_size = 100 @@ -98,6 +100,34 @@ def __init__( # Core HTTP Request Utilities # ============================================================================ + @property + def rate_limit_info(self) -> RateLimitInfo | None: + """Rate limit info from the most recent API response.""" + return self._last_rate_limit_info + + @staticmethod + def _parse_rate_limit_headers(headers: httpx.Headers) -> RateLimitInfo: + """Extract rate limit info from response headers.""" + def _int_or_none(val: str | None) -> int | None: + if val is None: + return None + try: + return int(val) + except (ValueError, TypeError): + return None + + return RateLimitInfo( + limit=_int_or_none(headers.get("X-RateLimit-Limit")), + remaining=_int_or_none(headers.get("X-RateLimit-Remaining")), + reset=_int_or_none(headers.get("X-RateLimit-Reset")), + daily_limit=_int_or_none(headers.get("X-RateLimit-Daily-Limit")), + daily_remaining=_int_or_none(headers.get("X-RateLimit-Daily-Remaining")), + daily_reset=_int_or_none(headers.get("X-RateLimit-Daily-Reset")), + burst_limit=_int_or_none(headers.get("X-RateLimit-Burst-Limit")), + burst_remaining=_int_or_none(headers.get("X-RateLimit-Burst-Remaining")), + burst_reset=_int_or_none(headers.get("X-RateLimit-Burst-Reset")), + ) + def _request( self, method: str, @@ -110,6 +140,7 @@ def _request( try: response = self.client.request(method=method, url=url, params=params, json=json_data) + self._last_rate_limit_info = self._parse_rate_limit_headers(response.headers) if response.status_code == 401: raise TangoAuthError( @@ -136,7 +167,9 @@ def _request( error_data, ) elif response.status_code == 429: - raise TangoRateLimitError("Rate limit exceeded", response.status_code) + error_data = response.json() if response.content else {} + detail = error_data.get("detail", "Rate limit exceeded") + raise TangoRateLimitError(detail, response.status_code, error_data) elif not response.is_success: raise TangoAPIError( f"API request failed with status {response.status_code}", response.status_code diff --git a/tango/exceptions.py b/tango/exceptions.py index 67c4d8c..a330f73 100644 --- a/tango/exceptions.py +++ b/tango/exceptions.py @@ -39,7 +39,34 @@ class TangoValidationError(TangoAPIError): class TangoRateLimitError(TangoAPIError): """Rate limit exceeded error""" - pass + @property + def wait_in_seconds(self) -> int | None: + """Seconds to wait before retrying, from API response.""" + val = self.response_data.get("wait_in_seconds") + if val is not None: + try: + return int(val) + except (ValueError, TypeError): + return None + return None + + @property + def detail(self) -> str | None: + """Human-readable detail from API response.""" + return self.response_data.get("detail") + + @property + def limit_type(self) -> str | None: + """Which limit was hit: 'burst' or 'daily', parsed from detail.""" + d = self.detail + if not d: + return None + lower = d.lower() + if "burst" in lower or "minute" in lower: + return "burst" + if "daily" in lower or "day" in lower: + return "daily" + return None class ShapeError(TangoAPIError): diff --git a/tango/models.py b/tango/models.py index 7d01056..7347a0d 100644 --- a/tango/models.py +++ b/tango/models.py @@ -23,6 +23,21 @@ # ============================================================================ +@dataclass +class RateLimitInfo: + """Rate limit information from API response headers.""" + + limit: int | None = None + remaining: int | None = None + reset: int | None = None + daily_limit: int | None = None + daily_remaining: int | None = None + daily_reset: int | None = None + burst_limit: int | None = None + burst_remaining: int | None = None + burst_reset: int | None = None + + @dataclass class SearchFilters: """Search filter parameters for contract search diff --git a/tests/test_client.py b/tests/test_client.py index 0d3c179..3ffb15d 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1065,10 +1065,16 @@ def test_400_validation_error_no_content(self, mock_request): @patch("tango.client.httpx.Client.request") def test_429_rate_limit_error(self, mock_request): - """Test 429 Rate Limit raises TangoRateLimitError""" + """Test 429 Rate Limit raises TangoRateLimitError with parsed body""" mock_response = Mock() mock_response.is_success = False mock_response.status_code = 429 + mock_response.content = b'{"detail": "Rate limit exceeded for burst. Please try again in 45 seconds.", "wait_in_seconds": 45}' + mock_response.json.return_value = { + "detail": "Rate limit exceeded for burst. Please try again in 45 seconds.", + "wait_in_seconds": 45, + } + mock_response.headers = {} mock_request.return_value = mock_response client = TangoClient(api_key="test-key") @@ -1077,6 +1083,85 @@ def test_429_rate_limit_error(self, mock_request): client.list_agencies() assert exc_info.value.status_code == 429 + assert exc_info.value.wait_in_seconds == 45 + assert "burst" in exc_info.value.detail + assert exc_info.value.limit_type == "burst" + + @patch("tango.client.httpx.Client.request") + def test_429_daily_limit_error(self, mock_request): + """Test 429 for daily limit includes correct limit_type""" + mock_response = Mock() + mock_response.is_success = False + mock_response.status_code = 429 + mock_response.content = b'{"detail": "Rate limit exceeded for daily. Please try again in 3600 seconds.", "wait_in_seconds": 3600}' + mock_response.json.return_value = { + "detail": "Rate limit exceeded for daily. Please try again in 3600 seconds.", + "wait_in_seconds": 3600, + } + mock_response.headers = {} + mock_request.return_value = mock_response + + client = TangoClient(api_key="test-key") + + with pytest.raises(TangoRateLimitError) as exc_info: + client.list_agencies() + + assert exc_info.value.limit_type == "daily" + assert exc_info.value.wait_in_seconds == 3600 + + @patch("tango.client.httpx.Client.request") + def test_429_empty_body(self, mock_request): + """Test 429 with no content body still works""" + mock_response = Mock() + mock_response.is_success = False + mock_response.status_code = 429 + mock_response.content = None + mock_response.headers = {} + mock_request.return_value = mock_response + + client = TangoClient(api_key="test-key") + + with pytest.raises(TangoRateLimitError) as exc_info: + client.list_agencies() + + assert exc_info.value.status_code == 429 + assert exc_info.value.wait_in_seconds is None + assert exc_info.value.limit_type is None + + @patch("tango.client.httpx.Client.request") + def test_rate_limit_headers_parsed(self, mock_request): + """Test rate limit headers are parsed from successful responses""" + mock_response = Mock() + mock_response.is_success = True + mock_response.status_code = 200 + mock_response.content = b'{"results": []}' + mock_response.json.return_value = {"results": []} + mock_response.headers = { + "X-RateLimit-Limit": "100", + "X-RateLimit-Remaining": "95", + "X-RateLimit-Reset": "45", + "X-RateLimit-Daily-Limit": "2400", + "X-RateLimit-Daily-Remaining": "2350", + "X-RateLimit-Daily-Reset": "86400", + "X-RateLimit-Burst-Limit": "100", + "X-RateLimit-Burst-Remaining": "95", + "X-RateLimit-Burst-Reset": "45", + } + mock_request.return_value = mock_response + + client = TangoClient(api_key="test-key") + assert client.rate_limit_info is None + + client._request("GET", "/api/agencies/") + + info = client.rate_limit_info + assert info is not None + assert info.limit == 100 + assert info.remaining == 95 + assert info.reset == 45 + assert info.daily_limit == 2400 + assert info.daily_remaining == 2350 + assert info.burst_remaining == 95 @patch("tango.client.httpx.Client.request") def test_500_server_error(self, mock_request): From 404183859105d0de1af81b24c1575d27008f640c Mon Sep 17 00:00:00 2001 From: "V. David Zvenyach" Date: Sat, 21 Mar 2026 05:15:06 -0500 Subject: [PATCH 2/2] Bump to v0.4.3 with changelog for rate limit handling Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 10 ++++++++++ pyproject.toml | 2 +- tango/__init__.py | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81382e2..6575df5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.3] - 2026-03-21 + +### Added +- `TangoRateLimitError` now exposes `wait_in_seconds`, `detail`, and `limit_type` properties parsed from the API's 429 response body. +- `RateLimitInfo` dataclass for structured access to rate limit headers (`X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, and per-window daily/burst variants). +- `TangoClient.rate_limit_info` property returns rate limit info from the most recent API response. + +### Changed +- `_request` now passes the full 429 response body to `TangoRateLimitError` (previously discarded), enabling callers to access `wait_in_seconds` and the specific limit type that was exceeded. + ## [0.4.2] - 2026-03-04 ### Added diff --git a/pyproject.toml b/pyproject.toml index 2677ca8..7d36563 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "tango-python" -version = "0.4.2" +version = "0.4.3" description = "Python SDK for the Tango API" readme = "README.md" requires-python = ">=3.12" diff --git a/tango/__init__.py b/tango/__init__.py index 72b6b13..480cf84 100644 --- a/tango/__init__.py +++ b/tango/__init__.py @@ -28,7 +28,7 @@ TypeGenerator, ) -__version__ = "0.4.1" +__version__ = "0.4.3" __all__ = [ "TangoClient", "TangoAPIError",