Coverage for packages / tab_client / client.py: 81%
110 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-08 08:37 +1200
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-08 08:37 +1200
1"""TAB Affiliates API client with retry logic.
3Handles retries, rate limiting, and error handling for the public TAB API.
4No authentication required - this is a public API.
5"""
7from __future__ import annotations
9import asyncio
10from typing import TYPE_CHECKING, Any
12import httpx
14from packages.core.common.logging import get_logger
15from packages.core.common.settings import get_settings
17logger = get_logger(__name__)
19if TYPE_CHECKING:
20 from packages.tab_client.mock_client import MockTABClient
23class TABClientError(Exception):
24 """Base exception for TAB client errors."""
26 pass
29class TABRateLimitError(TABClientError):
30 """Rate limit exceeded."""
32 pass
35class TABClient:
36 """Client for TAB Affiliates API with retry logic.
38 The TAB Affiliates API is a public API that provides racing data
39 for NZ and international racing. No authentication is required.
41 Example:
42 >>> async with TABClient() as client:
43 >>> meetings = await client.get_meetings("2024-01-01", "2024-01-07")
44 >>> for meeting in meetings:
45 >>> event_id = meeting["races"][0]["id"]
46 >>> event = await client.get_event(event_id)
47 """
49 def __init__(self):
50 """Initialize TAB client with settings."""
51 self.settings = get_settings()
52 self.tab_config = self.settings.tab
53 self._client: httpx.AsyncClient | None = None
55 async def __aenter__(self):
56 """Async context manager entry."""
57 await self._ensure_client()
58 return self
60 async def __aexit__(self, exc_type, exc_val, exc_tb):
61 """Async context manager exit."""
62 await self.close()
64 async def _ensure_client(self):
65 """Ensure HTTP client is initialized."""
66 if self._client is None:
67 self._client = httpx.AsyncClient(
68 timeout=self.tab_config.timeout,
69 follow_redirects=True,
70 )
72 async def close(self):
73 """Close HTTP client."""
74 if self._client is not None:
75 await self._client.aclose()
76 self._client = None
78 async def _request_with_retry(
79 self,
80 method: str,
81 endpoint: str,
82 params: dict[str, Any] | None = None,
83 retry_count: int = 0,
84 ) -> dict[str, Any]:
85 """Make HTTP request with retry logic.
87 Args:
88 method: HTTP method (GET, POST, etc.)
89 endpoint: API endpoint path (without base URL)
90 params: Query parameters
91 retry_count: Current retry attempt
93 Returns:
94 Response JSON data
96 Raises:
97 TABClientError: If request fails after retries
98 """
99 await self._ensure_client()
101 url = f"{self.tab_config.base_url}/{endpoint}"
103 # Debug logging
104 logger.debug(
105 f"Making {method} request to {url}",
106 extra={
107 "endpoint": endpoint,
108 "params": params,
109 },
110 )
112 try:
113 response = await self._client.request(method, url, params=params)
115 logger.debug(
116 f"Response status: {response.status_code}",
117 extra={"status_code": response.status_code, "endpoint": endpoint},
118 )
120 # Handle rate limiting
121 if response.status_code == 429:
122 if retry_count >= self.tab_config.max_retries:
123 raise TABRateLimitError("Rate limit exceeded, max retries reached")
125 # Exponential backoff
126 wait_time = 2**retry_count
127 logger.warning(f"Rate limited, waiting {wait_time}s before retry")
128 await asyncio.sleep(wait_time)
129 return await self._request_with_retry(
130 method, endpoint, params, retry_count + 1
131 )
133 # Handle client errors
134 if response.status_code == 400:
135 try:
136 error_body = response.json()
137 error_msg = error_body.get("error", response.text)
138 except Exception:
139 error_msg = response.text
140 raise TABClientError(f"Bad request: {error_msg}")
142 # Handle not found
143 if response.status_code == 404:
144 raise TABClientError(f"Resource not found: {endpoint}")
146 response.raise_for_status()
147 return response.json()
149 except httpx.HTTPStatusError as e:
150 if retry_count >= self.tab_config.max_retries:
151 raise TABClientError(f"HTTP error: {e}") from e
153 # Retry on 5xx errors
154 if 500 <= e.response.status_code < 600:
155 wait_time = 2**retry_count
156 logger.warning(
157 f"Server error {e.response.status_code}, "
158 f"waiting {wait_time}s before retry"
159 )
160 await asyncio.sleep(wait_time)
161 return await self._request_with_retry(
162 method, endpoint, params, retry_count + 1
163 )
165 raise TABClientError(f"HTTP error: {e}") from e
167 except httpx.RequestError as e:
168 if retry_count >= self.tab_config.max_retries:
169 raise TABClientError(f"Request error: {e}") from e
171 wait_time = 2**retry_count
172 logger.warning(f"Request failed, waiting {wait_time}s before retry")
173 await asyncio.sleep(wait_time)
174 return await self._request_with_retry(
175 method, endpoint, params, retry_count + 1
176 )
178 async def get_meetings(
179 self,
180 date_from: str,
181 date_to: str,
182 category: str | None = None,
183 country: str | None = None,
184 ) -> list[dict[str, Any]]:
185 """Fetch meetings within date range.
187 Args:
188 date_from: Start date (YYYY-MM-DD)
189 date_to: End date (YYYY-MM-DD)
190 category: Racing category filter - "T" (Thoroughbred),
191 "H" (Harness), "G" (Greyhound). If None, uses config default.
192 country: Country filter (e.g., "NZ", "AUS"). If None, uses config default.
194 Returns:
195 List of meeting data dictionaries
197 Example:
198 >>> async with TABClient() as client:
199 >>> meetings = await client.get_meetings(
200 >>> "2024-01-01", "2024-01-07", category="H", country="NZ"
201 >>> )
202 """
203 # Use config defaults if not specified
204 if category is None:
205 category = self.tab_config.default_category
206 if country is None:
207 country = self.tab_config.default_country
209 logger.info(
210 f"Fetching {category} meetings from {date_from} to {date_to} "
211 f"in {country}"
212 )
214 params = {
215 "date_from": date_from,
216 "date_to": date_to,
217 "category": category,
218 "country": country,
219 }
221 response = await self._request_with_retry("GET", "racing/meetings", params)
223 # TAB API returns {"meetings": [...]}
224 meetings = response.get("meetings", [])
225 logger.info(f"Found {len(meetings)} meetings")
226 return meetings
228 async def get_meeting(self, meeting_id: str) -> dict[str, Any]:
229 """Fetch single meeting details with race summaries.
231 Args:
232 meeting_id: TAB meeting ID (string)
234 Returns:
235 Meeting data dictionary with 'races' array (summaries only)
237 Note:
238 The races in this response only contain summary info (id, race_number).
239 Use get_event() to fetch full race details with runners.
240 """
241 logger.info(f"Fetching meeting {meeting_id}")
243 response = await self._request_with_retry(
244 "GET", f"racing/meetings/{meeting_id}"
245 )
247 # TAB API returns {"meetings": [single_meeting]}
248 meetings = response.get("meetings", [])
249 if not meetings:
250 raise TABClientError(f"Meeting not found: {meeting_id}")
252 return meetings[0]
254 async def get_event(self, event_id: str) -> dict[str, Any]:
255 """Fetch race/event details with runners and results.
257 Args:
258 event_id: TAB event ID (string)
260 Returns:
261 Event data dictionary with:
262 - race: Race information (distance, start_type, etc.)
263 - runners: List of runners with horse, driver, trainer info
264 - results: List of results with positions (if race is finished)
265 - dividends: Dividend information (if available)
267 Example:
268 >>> async with TABClient() as client:
269 >>> event = await client.get_event("abc123")
270 >>> runners = event.get("runners", [])
271 >>> results = event.get("results", [])
272 """
273 logger.info(f"Fetching event {event_id}")
275 response = await self._request_with_retry("GET", f"racing/events/{event_id}")
277 # TAB API wraps event data in a "data" key
278 # Response: {"data": {"race": {...}, "runners": [...], "results": [...]}}
279 data = response.get("data", {})
281 if not data:
282 logger.warning(f"Event {event_id} returned empty data")
284 return data
286 async def get_races_list(
287 self,
288 date_from: str,
289 date_to: str,
290 meet_types: str | None = None,
291 countries: str | None = None,
292 limit: int = 100,
293 ) -> dict[str, Any]:
294 """Fetch list of races with pagination.
296 This is an alternative to get_meetings() that returns races directly
297 with more filtering options.
299 Args:
300 date_from: Start date (YYYY-MM-DD)
301 date_to: End date (YYYY-MM-DD)
302 meet_types: Racing type filter - "T", "H", "G" (can be combined)
303 countries: Country filter - "NZ", "AUS", etc.
304 limit: Maximum results per page (max 200)
306 Returns:
307 Dictionary with:
308 - races: List of race data
309 - page_token: Token for next page (if more results)
311 Note:
312 For paginated results, use page_token in subsequent requests.
313 """
314 if meet_types is None:
315 meet_types = self.tab_config.default_category
316 if countries is None:
317 countries = self.tab_config.default_country
319 logger.info(
320 f"Fetching race list from {date_from} to {date_to} "
321 f"(types={meet_types}, countries={countries})"
322 )
324 params = {
325 "date_from": date_from,
326 "date_to": date_to,
327 "meet_types": meet_types,
328 "countries": countries,
329 "limit": min(limit, 200), # API max is 200
330 }
332 response = await self._request_with_retry("GET", "racing/list", params)
333 return response
336def get_client() -> TABClient | MockTABClient:
337 """Get TAB client (real or mock based on settings).
339 Returns:
340 TABClient or MockTABClient instance
342 Example:
343 >>> async with get_client() as client:
344 >>> meetings = await client.get_meetings("2024-01-01", "2024-01-07")
345 """
346 settings = get_settings()
348 if settings.tab.mock_mode:
349 from packages.tab_client.mock_client import MockTABClient
351 logger.info("Using MockTABClient (TAB_MOCK_MODE=true)")
352 return MockTABClient()
353 else:
354 return TABClient()