|
17 | 17 | from .const import ( |
18 | 18 | API_BASE_TEMPLATE, |
19 | 19 | AUTH_URL, |
| 20 | + CLIENT_ID_CACHE_TTL, |
20 | 21 | DEFAULT_API_VERSION, |
21 | 22 | DEFAULT_CACHE_TTL, |
22 | 23 | DEFAULT_CLIENT_ID, |
23 | 24 | DEFAULT_CONN_TIMEOUT, |
24 | 25 | DEFAULT_JITTER_MAX, |
25 | 26 | DEFAULT_KEEPALIVE_TIMEOUT, |
| 27 | + DEFAULT_MAX_CONCURRENT_REQUESTS, |
26 | 28 | DEFAULT_MAX_CONNECTIONS, |
27 | 29 | DEFAULT_READ_TIMEOUT, |
28 | 30 | DEFAULT_TIMEOUT, |
@@ -143,6 +145,9 @@ def __init__( |
143 | 145 | hass=hass, |
144 | 146 | ) |
145 | 147 |
|
| 148 | + # Semaphore to cap concurrent API requests and avoid rate-limit errors |
| 149 | + self._request_semaphore = asyncio.Semaphore(DEFAULT_MAX_CONCURRENT_REQUESTS) |
| 150 | + |
146 | 151 | # Set up retry handler |
147 | 152 | self._retry_handler = RetryWithBackoff( |
148 | 153 | max_retries=max_retries, |
@@ -224,7 +229,8 @@ async def _ensure_client_id(self) -> str: |
224 | 229 | """Ensure the OAuth client ID is up to date.""" |
225 | 230 | now = time.time() |
226 | 231 | cache_valid = ( |
227 | | - self._client_id_last_fetch is not None and now - self._client_id_last_fetch < 3600 |
| 232 | + self._client_id_last_fetch is not None |
| 233 | + and now - self._client_id_last_fetch < CLIENT_ID_CACHE_TTL |
228 | 234 | ) |
229 | 235 |
|
230 | 236 | if cache_valid and self._client_id: |
@@ -335,77 +341,80 @@ async def _request( |
335 | 341 | for version in self._version_candidates(version_override): |
336 | 342 | url = self._build_api_url(path, version) |
337 | 343 | try: |
338 | | - async with session.request( |
339 | | - method, |
340 | | - url, |
341 | | - params=params, |
342 | | - json=json_data, |
343 | | - headers=self._get_headers(), |
344 | | - timeout=DEFAULT_TIMEOUT, |
345 | | - ) as response: |
346 | | - if response.status in acceptable_status: |
347 | | - self._set_api_version(version) |
348 | | - if not return_json: |
349 | | - return await response.text() |
350 | | - # Always attempt JSON parsing when return_json=True |
351 | | - # API may include charset in content-type |
352 | | - # (e.g., "application/json; charset=utf-8") |
353 | | - try: |
354 | | - return await response.json() |
355 | | - except (aiohttp.ContentTypeError, json.JSONDecodeError) as e: |
356 | | - # Log diagnostic info for troubleshooting |
357 | | - response_text = await response.text() |
| 344 | + async with self._request_semaphore: |
| 345 | + async with session.request( |
| 346 | + method, |
| 347 | + url, |
| 348 | + params=params, |
| 349 | + json=json_data, |
| 350 | + headers=self._get_headers(), |
| 351 | + timeout=DEFAULT_TIMEOUT, |
| 352 | + ) as response: |
| 353 | + if response.status in acceptable_status: |
| 354 | + self._set_api_version(version) |
| 355 | + if not return_json: |
| 356 | + return await response.text() |
| 357 | + # Always attempt JSON parsing when return_json=True |
| 358 | + # API may include charset in content-type |
| 359 | + # (e.g., "application/json; charset=utf-8") |
| 360 | + try: |
| 361 | + return await response.json() |
| 362 | + except (aiohttp.ContentTypeError, json.JSONDecodeError) as e: |
| 363 | + # Log diagnostic info for troubleshooting |
| 364 | + response_text = await response.text() |
| 365 | + _LOGGER.warning( |
| 366 | + "Failed to parse JSON from %s (content-type: %s): %s. Response: %s", |
| 367 | + url, |
| 368 | + response.content_type, |
| 369 | + e, |
| 370 | + response_text[:200], |
| 371 | + ) |
| 372 | + raise ApiError( |
| 373 | + f"Non-JSON response from {url}: {response_text[:200]}" |
| 374 | + ) from e |
| 375 | + |
| 376 | + if response.status == 401: |
| 377 | + # Attempt token refresh once when unauthorized |
358 | 378 | _LOGGER.warning( |
359 | | - "Failed to parse JSON from %s (content-type: %s): %s. Response: %s", |
360 | | - url, |
361 | | - response.content_type, |
362 | | - e, |
363 | | - response_text[:200], |
| 379 | + "API request unauthorized for %s, refreshing token", url |
364 | 380 | ) |
365 | | - # Return text as fallback, but this will likely |
366 | | - # cause errors in calling code |
367 | | - return response_text |
368 | | - |
369 | | - if response.status == 401: |
370 | | - # Attempt token refresh once when unauthorized |
371 | | - _LOGGER.warning("API request unauthorized for %s, refreshing token", url) |
372 | | - if retry_on_unauthorized: |
373 | | - await self.authenticate() |
374 | | - await self._save_token_to_cache() |
375 | | - return await self._request( |
376 | | - method, |
| 381 | + if retry_on_unauthorized: |
| 382 | + await self.authenticate() |
| 383 | + await self._save_token_to_cache() |
| 384 | + return await self._request( |
| 385 | + method, |
| 386 | + path, |
| 387 | + params=params, |
| 388 | + json_data=json_data, |
| 389 | + acceptable_status=acceptable_status, |
| 390 | + version_override=version, |
| 391 | + return_json=return_json, |
| 392 | + retry_on_unauthorized=False, |
| 393 | + ) |
| 394 | + last_error = AuthenticationError("Unauthorized") |
| 395 | + continue |
| 396 | + |
| 397 | + if response.status == 403: |
| 398 | + last_error = AuthenticationError( |
| 399 | + f"Forbidden request to {path}: {response.status}" |
| 400 | + ) |
| 401 | + break |
| 402 | + |
| 403 | + # Try next version on not found errors when fallbacks are allowed |
| 404 | + if response.status in (404, 400) and version_override is None: |
| 405 | + _LOGGER.debug( |
| 406 | + "API version %s returned %s for %s, trying fallback", |
| 407 | + version, |
| 408 | + response.status, |
377 | 409 | path, |
378 | | - params=params, |
379 | | - json_data=json_data, |
380 | | - acceptable_status=acceptable_status, |
381 | | - version_override=version, |
382 | | - return_json=return_json, |
383 | | - retry_on_unauthorized=False, |
384 | 410 | ) |
385 | | - last_error = AuthenticationError("Unauthorized") |
386 | | - continue |
387 | | - |
388 | | - if response.status == 403: |
389 | | - last_error = AuthenticationError( |
390 | | - f"Forbidden request to {path}: {response.status}" |
391 | | - ) |
392 | | - break |
| 411 | + last_error = ApiError(f"Endpoint {path} unavailable") |
| 412 | + continue |
393 | 413 |
|
394 | | - # Try next version on not found errors when fallbacks are allowed |
395 | | - if response.status in (404, 400) and version_override is None: |
396 | | - _LOGGER.debug( |
397 | | - "API version %s returned %s for %s, trying fallback", |
398 | | - version, |
399 | | - response.status, |
400 | | - path, |
| 414 | + response_text = await response.text() |
| 415 | + last_error = ApiError( |
| 416 | + f"Unexpected status {response.status} for {path}: {response_text[:200]}" |
401 | 417 | ) |
402 | | - last_error = ApiError(f"Endpoint {path} unavailable") |
403 | | - continue |
404 | | - |
405 | | - response_text = await response.text() |
406 | | - last_error = ApiError( |
407 | | - f"Unexpected status {response.status} for {path}: {response_text[:200]}" |
408 | | - ) |
409 | 418 | except AuthenticationError: |
410 | 419 | raise |
411 | 420 | except aiohttp.ClientError as err: |
@@ -866,11 +875,15 @@ async def _follow_auth_redirects(self, initial_url: str) -> str | None: |
866 | 875 | return token |
867 | 876 |
|
868 | 877 | # If we've reached owners.vacasa.com without a token, try one more request |
869 | | - # Use proper hostname parsing to prevent URL manipulation attacks |
| 878 | + # Use domain-parts check: ensure last 3 labels are owners.vacasa.com |
| 879 | + # This prevents false matches like fake-owners.vacasa.com |
870 | 880 | response_host = str(response.url.host).lower() if response.url.host else "" |
871 | | - if response_host == "owners.vacasa.com" or response_host.endswith( |
872 | | - ".owners.vacasa.com" |
873 | | - ): |
| 881 | + host_parts = response_host.split(".") |
| 882 | + is_owners_host = ( |
| 883 | + len(host_parts) >= 3 |
| 884 | + and ".".join(host_parts[-3:]) == "owners.vacasa.com" |
| 885 | + ) |
| 886 | + if is_owners_host: |
874 | 887 | page_content = await response.text() |
875 | 888 | token_match = re.search(r'access_token=([^&"\']+)', page_content) |
876 | 889 | if token_match: |
|
0 commit comments