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

1"""TAB Affiliates API client with retry logic. 

2 

3Handles retries, rate limiting, and error handling for the public TAB API. 

4No authentication required - this is a public API. 

5""" 

6 

7from __future__ import annotations 

8 

9import asyncio 

10from typing import TYPE_CHECKING, Any 

11 

12import httpx 

13 

14from packages.core.common.logging import get_logger 

15from packages.core.common.settings import get_settings 

16 

17logger = get_logger(__name__) 

18 

19if TYPE_CHECKING: 

20 from packages.tab_client.mock_client import MockTABClient 

21 

22 

23class TABClientError(Exception): 

24 """Base exception for TAB client errors.""" 

25 

26 pass 

27 

28 

29class TABRateLimitError(TABClientError): 

30 """Rate limit exceeded.""" 

31 

32 pass 

33 

34 

35class TABClient: 

36 """Client for TAB Affiliates API with retry logic. 

37 

38 The TAB Affiliates API is a public API that provides racing data 

39 for NZ and international racing. No authentication is required. 

40 

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 """ 

48 

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 

54 

55 async def __aenter__(self): 

56 """Async context manager entry.""" 

57 await self._ensure_client() 

58 return self 

59 

60 async def __aexit__(self, exc_type, exc_val, exc_tb): 

61 """Async context manager exit.""" 

62 await self.close() 

63 

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 ) 

71 

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 

77 

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. 

86 

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 

92 

93 Returns: 

94 Response JSON data 

95 

96 Raises: 

97 TABClientError: If request fails after retries 

98 """ 

99 await self._ensure_client() 

100 

101 url = f"{self.tab_config.base_url}/{endpoint}" 

102 

103 # Debug logging 

104 logger.debug( 

105 f"Making {method} request to {url}", 

106 extra={ 

107 "endpoint": endpoint, 

108 "params": params, 

109 }, 

110 ) 

111 

112 try: 

113 response = await self._client.request(method, url, params=params) 

114 

115 logger.debug( 

116 f"Response status: {response.status_code}", 

117 extra={"status_code": response.status_code, "endpoint": endpoint}, 

118 ) 

119 

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") 

124 

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 ) 

132 

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}") 

141 

142 # Handle not found 

143 if response.status_code == 404: 

144 raise TABClientError(f"Resource not found: {endpoint}") 

145 

146 response.raise_for_status() 

147 return response.json() 

148 

149 except httpx.HTTPStatusError as e: 

150 if retry_count >= self.tab_config.max_retries: 

151 raise TABClientError(f"HTTP error: {e}") from e 

152 

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 ) 

164 

165 raise TABClientError(f"HTTP error: {e}") from e 

166 

167 except httpx.RequestError as e: 

168 if retry_count >= self.tab_config.max_retries: 

169 raise TABClientError(f"Request error: {e}") from e 

170 

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 ) 

177 

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. 

186 

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. 

193 

194 Returns: 

195 List of meeting data dictionaries 

196 

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 

208 

209 logger.info( 

210 f"Fetching {category} meetings from {date_from} to {date_to} " 

211 f"in {country}" 

212 ) 

213 

214 params = { 

215 "date_from": date_from, 

216 "date_to": date_to, 

217 "category": category, 

218 "country": country, 

219 } 

220 

221 response = await self._request_with_retry("GET", "racing/meetings", params) 

222 

223 # TAB API returns {"meetings": [...]} 

224 meetings = response.get("meetings", []) 

225 logger.info(f"Found {len(meetings)} meetings") 

226 return meetings 

227 

228 async def get_meeting(self, meeting_id: str) -> dict[str, Any]: 

229 """Fetch single meeting details with race summaries. 

230 

231 Args: 

232 meeting_id: TAB meeting ID (string) 

233 

234 Returns: 

235 Meeting data dictionary with 'races' array (summaries only) 

236 

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}") 

242 

243 response = await self._request_with_retry( 

244 "GET", f"racing/meetings/{meeting_id}" 

245 ) 

246 

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}") 

251 

252 return meetings[0] 

253 

254 async def get_event(self, event_id: str) -> dict[str, Any]: 

255 """Fetch race/event details with runners and results. 

256 

257 Args: 

258 event_id: TAB event ID (string) 

259 

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) 

266 

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}") 

274 

275 response = await self._request_with_retry("GET", f"racing/events/{event_id}") 

276 

277 # TAB API wraps event data in a "data" key 

278 # Response: {"data": {"race": {...}, "runners": [...], "results": [...]}} 

279 data = response.get("data", {}) 

280 

281 if not data: 

282 logger.warning(f"Event {event_id} returned empty data") 

283 

284 return data 

285 

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. 

295 

296 This is an alternative to get_meetings() that returns races directly 

297 with more filtering options. 

298 

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) 

305 

306 Returns: 

307 Dictionary with: 

308 - races: List of race data 

309 - page_token: Token for next page (if more results) 

310 

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 

318 

319 logger.info( 

320 f"Fetching race list from {date_from} to {date_to} " 

321 f"(types={meet_types}, countries={countries})" 

322 ) 

323 

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 } 

331 

332 response = await self._request_with_retry("GET", "racing/list", params) 

333 return response 

334 

335 

336def get_client() -> TABClient | MockTABClient: 

337 """Get TAB client (real or mock based on settings). 

338 

339 Returns: 

340 TABClient or MockTABClient instance 

341 

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() 

347 

348 if settings.tab.mock_mode: 

349 from packages.tab_client.mock_client import MockTABClient 

350 

351 logger.info("Using MockTABClient (TAB_MOCK_MODE=true)") 

352 return MockTABClient() 

353 else: 

354 return TABClient()