Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 3 additions & 1 deletion tango/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from .models import (
GsaElibraryContract,
PaginatedResponse,
RateLimitInfo,
SearchFilters,
ShapeConfig,
WebhookEndpoint,
Expand All @@ -27,14 +28,15 @@
TypeGenerator,
)

__version__ = "0.4.1"
__version__ = "0.4.3"
__all__ = [
"TangoClient",
"TangoAPIError",
"TangoAuthError",
"TangoNotFoundError",
"TangoValidationError",
"TangoRateLimitError",
"RateLimitInfo",
"GsaElibraryContract",
"PaginatedResponse",
"SearchFilters",
Expand Down
35 changes: 34 additions & 1 deletion tango/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
Organization,
PaginatedResponse,
Protest,
RateLimitInfo,
SearchFilters,
ShapeConfig,
Subaward,
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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(
Expand All @@ -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
Expand Down
29 changes: 28 additions & 1 deletion tango/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
15 changes: 15 additions & 0 deletions tango/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
87 changes: 86 additions & 1 deletion tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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):
Expand Down
Loading