Skip to content

Commit 01718d0

Browse files
authored
Merge pull request #94 from samspade21/claude/review-code-bugs-5BMmj
Refactor: Extract constants and add request rate limiting
2 parents a04fada + 838e904 commit 01718d0

File tree

5 files changed

+122
-105
lines changed

5 files changed

+122
-105
lines changed

custom_components/vacasa/api_client.py

Lines changed: 83 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,14 @@
1717
from .const import (
1818
API_BASE_TEMPLATE,
1919
AUTH_URL,
20+
CLIENT_ID_CACHE_TTL,
2021
DEFAULT_API_VERSION,
2122
DEFAULT_CACHE_TTL,
2223
DEFAULT_CLIENT_ID,
2324
DEFAULT_CONN_TIMEOUT,
2425
DEFAULT_JITTER_MAX,
2526
DEFAULT_KEEPALIVE_TIMEOUT,
27+
DEFAULT_MAX_CONCURRENT_REQUESTS,
2628
DEFAULT_MAX_CONNECTIONS,
2729
DEFAULT_READ_TIMEOUT,
2830
DEFAULT_TIMEOUT,
@@ -143,6 +145,9 @@ def __init__(
143145
hass=hass,
144146
)
145147

148+
# Semaphore to cap concurrent API requests and avoid rate-limit errors
149+
self._request_semaphore = asyncio.Semaphore(DEFAULT_MAX_CONCURRENT_REQUESTS)
150+
146151
# Set up retry handler
147152
self._retry_handler = RetryWithBackoff(
148153
max_retries=max_retries,
@@ -224,7 +229,8 @@ async def _ensure_client_id(self) -> str:
224229
"""Ensure the OAuth client ID is up to date."""
225230
now = time.time()
226231
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
228234
)
229235

230236
if cache_valid and self._client_id:
@@ -335,77 +341,80 @@ async def _request(
335341
for version in self._version_candidates(version_override):
336342
url = self._build_api_url(path, version)
337343
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
358378
_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
364380
)
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,
377409
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,
384410
)
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
393413

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]}"
401417
)
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-
)
409418
except AuthenticationError:
410419
raise
411420
except aiohttp.ClientError as err:
@@ -866,11 +875,15 @@ async def _follow_auth_redirects(self, initial_url: str) -> str | None:
866875
return token
867876

868877
# 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
870880
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:
874887
page_content = await response.text()
875888
token_match = re.search(r'access_token=([^&"\']+)', page_content)
876889
if token_match:

custom_components/vacasa/binary_sensor.py

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
from __future__ import annotations
44

5-
import asyncio
65
import logging
76
from datetime import datetime
87
from typing import Any
@@ -176,20 +175,14 @@ def _refresh_from_coordinator(self) -> None:
176175
return
177176
self._update_from_state(state)
178177

178+
@callback
179179
def _handle_reservation_state(self, unit_id: str, state: ReservationState) -> None:
180180
"""Handle reservation updates sent by the calendar entities."""
181181
if unit_id != self._unit_id:
182182
return
183183

184184
self._update_from_state(state)
185-
# Schedule state write on event loop thread-safely
186-
# Signal handlers execute on worker threads, so we must use call_soon_threadsafe
187-
hass = getattr(self, "hass", None)
188-
loop = getattr(hass, "loop", None)
189-
if isinstance(loop, asyncio.AbstractEventLoop):
190-
loop.call_soon_threadsafe(self.async_write_ha_state)
191-
else:
192-
self.async_write_ha_state()
185+
self.async_write_ha_state()
193186

194187
def _update_from_state(self, state: ReservationState) -> None:
195188
"""Store reservation state and mark availability."""
@@ -201,8 +194,8 @@ def _update_from_state(self, state: ReservationState) -> None:
201194

202195
# Log occupancy changes to help diagnose timing issues
203196
if old_occupancy != new_occupancy:
204-
_LOGGER.warning(
205-
"OCCUPANCY CHANGED for %s: %s -> %s. Current reservation: %s",
197+
_LOGGER.info(
198+
"Occupancy changed for %s: %s -> %s. Current reservation: %s",
206199
self._name,
207200
"occupied" if old_occupancy else "vacant",
208201
"occupied" if new_occupancy else "vacant",

custom_components/vacasa/calendar.py

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@
1616
from . import VacasaConfigEntry, VacasaDataUpdateCoordinator
1717
from .api_client import VacasaApiClient
1818
from .const import (
19+
CALENDAR_LOOKAHEAD_DAYS,
20+
CALENDAR_LOOKBACK_DAYS,
21+
DEFAULT_CHECKIN_TIME,
22+
DEFAULT_CHECKOUT_TIME,
1923
DOMAIN,
2024
SIGNAL_RESERVATION_BOUNDARY,
2125
SIGNAL_RESERVATION_STATE,
@@ -252,10 +256,10 @@ async def _determine_current_and_next_events(
252256
"""Determine the current and next events for the property."""
253257
now_local = dt_util.now()
254258
now_utc = dt_util.utcnow()
255-
# Query from 60 days ago to catch any currently active reservations that started in the past
256-
# but are still in progress (e.g., guest checking out today but checked in weeks ago)
257-
start_date = now_local - timedelta(days=60)
258-
end_date = now_local + timedelta(days=365)
259+
# Query from CALENDAR_LOOKBACK_DAYS ago to catch active reservations started in the past
260+
# (e.g., guest checking out today but checked in weeks ago)
261+
start_date = now_local - timedelta(days=CALENDAR_LOOKBACK_DAYS)
262+
end_date = now_local + timedelta(days=CALENDAR_LOOKAHEAD_DAYS)
259263
events = await self.async_get_events(self.hass, start_date, end_date)
260264

261265
_LOGGER.debug(
@@ -370,7 +374,7 @@ def _schedule_boundary_timers(self) -> None:
370374
partial(self._handle_boundary_timer, boundary="checkout"),
371375
end_utc,
372376
)
373-
_LOGGER.warning(
377+
_LOGGER.debug(
374378
"Scheduled checkout refresh for %s at %s "
375379
"(local: %s, original: %s with tz: %s). Event: %s",
376380
self._name,
@@ -389,7 +393,7 @@ def _schedule_boundary_timers(self) -> None:
389393
partial(self._handle_boundary_timer, boundary="checkin"),
390394
start_utc,
391395
)
392-
_LOGGER.warning(
396+
_LOGGER.debug(
393397
"Scheduled check-in refresh for %s at %s "
394398
"(local: %s, original: %s with tz: %s). Event: %s",
395399
self._name,
@@ -409,8 +413,8 @@ def _handle_boundary_timer(self, scheduled_time: datetime, *, boundary: str) ->
409413
if not self.hass:
410414
return
411415

412-
_LOGGER.warning(
413-
"BOUNDARY TIMER (%s) FIRED for %s! Scheduled: %s, Actual: %s",
416+
_LOGGER.debug(
417+
"Boundary timer (%s) fired for %s. Scheduled: %s, Actual: %s",
414418
boundary,
415419
self._name,
416420
scheduled_time.isoformat(),
@@ -532,8 +536,8 @@ def _reservation_to_event( # noqa: C901
532536
# Use property-specific time
533537
start_dt_str = f"{start_date}T{property_checkin_time}"
534538
else:
535-
# No time available, default to 4:00 PM (16:00)
536-
start_dt_str = f"{start_date}T16:00:00"
539+
# No time available, use default check-in time
540+
start_dt_str = f"{start_date}T{DEFAULT_CHECKIN_TIME}"
537541

538542
if checkout_time:
539543
# Use time from reservation
@@ -542,8 +546,8 @@ def _reservation_to_event( # noqa: C901
542546
# Use property-specific time
543547
end_dt_str = f"{end_date}T{property_checkout_time}"
544548
else:
545-
# No time available, default to 10:00 AM (10:00)
546-
end_dt_str = f"{end_date}T10:00:00"
549+
# No time available, use default checkout time
550+
end_dt_str = f"{end_date}T{DEFAULT_CHECKOUT_TIME}"
547551

548552
# Parse datetime strings
549553
start_dt = dt_util.parse_datetime(start_dt_str)
@@ -638,14 +642,14 @@ def _reservation_to_event( # noqa: C901
638642
elif self._checkin_time:
639643
description_parts.append(f"Check-in: {start_date} {self._checkin_time[:5]}")
640644
else:
641-
description_parts.append(f"Check-in: {start_date} 16:00")
645+
description_parts.append(f"Check-in: {start_date} {DEFAULT_CHECKIN_TIME[:5]}")
642646

643647
if checkout_time and checkout_time != "00:00:00":
644648
description_parts.append(f"Check-out: {end_date} {checkout_time[:5]}")
645649
elif self._checkout_time:
646650
description_parts.append(f"Check-out: {end_date} {self._checkout_time[:5]}")
647651
else:
648-
description_parts.append(f"Check-out: {end_date} 10:00")
652+
description_parts.append(f"Check-out: {end_date} {DEFAULT_CHECKOUT_TIME[:5]}")
649653

650654
description_parts.append("")
651655

custom_components/vacasa/const.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
# Performance optimization settings
2121
DEFAULT_CACHE_TTL = 3600 # seconds (1 hour) for property data
2222
DEFAULT_MAX_CONNECTIONS = 10
23+
DEFAULT_MAX_CONCURRENT_REQUESTS = 5 # max simultaneous API requests
2324
DEFAULT_KEEPALIVE_TIMEOUT = 30 # seconds
2425
DEFAULT_CONN_TIMEOUT = 30 # seconds
2526
DEFAULT_READ_TIMEOUT = 30 # seconds
@@ -90,3 +91,14 @@
9091
# Dispatcher signals
9192
SIGNAL_RESERVATION_BOUNDARY = "vacasa_reservation_boundary"
9293
SIGNAL_RESERVATION_STATE = "vacasa_reservation_state"
94+
95+
# Calendar event window constants
96+
CALENDAR_LOOKBACK_DAYS = 60 # days to look back for active reservations
97+
CALENDAR_LOOKAHEAD_DAYS = 365 # days to look ahead for future reservations
98+
99+
# Default reservation times when none are provided by the API
100+
DEFAULT_CHECKIN_TIME = "16:00:00" # 4:00 PM
101+
DEFAULT_CHECKOUT_TIME = "10:00:00" # 10:00 AM
102+
103+
# Client ID cache TTL (re-use DEFAULT_CACHE_TTL for this purpose)
104+
CLIENT_ID_CACHE_TTL = DEFAULT_CACHE_TTL # seconds

0 commit comments

Comments
 (0)