Parcourir la source

Fix StayHostingDetailsQuery timeout crashing get_reservations

ReadTimeout was caught by the TransportError handler in _gql and re-raised
as AirbnbAuthError, causing the whole airbnb service to be skipped on diff.

Fix: separate TimeoutException from TransportError in _gql so timeouts don't
masquerade as auth failures. In fetch_details, catch timeout per-reservation
and log a warning + return a null tuple (filtered by the existing if ci and co
guard) so one slow detail call can't abort the full reservation list.

Also add a Semaphore(3) so detail fetches don't all fire simultaneously.
ZiB il y a 4 mois
Parent
commit
dc0f54a62e
1 fichiers modifiés avec 51 ajouts et 42 suppressions
  1. 51 42
      room_cal/service_api/_airbnb.py

+ 51 - 42
room_cal/service_api/_airbnb.py

@@ -191,6 +191,8 @@ class _AirbnbClient:
         return await client.get(url, params=params, headers=self._headers(referer))
     try:
       r = await _attempt()
+    except httpx.TimeoutException as e:
+      raise httpx.ReadTimeout(f'Airbnb request timed out ({op})') from e
     except (httpx.TransportError, OSError, ExceptionGroup) as e:
       raise AirbnbAuthError(f'Airbnb connection failed — session may be expired, run: {_AUTH_HINT}') from e
     if r.status_code in (400, 401, 403):
@@ -323,49 +325,56 @@ class _AirbnbClient:
       dt = datetime.fromisoformat(ts.replace('Z', '+00:00'))
       return dt.astimezone(ZoneInfo(self._tz)).date().isoformat()
 
+    _sem = asyncio.Semaphore(3)
+
     async def fetch_details(c: str) -> tuple[str, str | None, str | None, str | None, str | None, str | None]:
-      resp = await self._gql(
-        'StayHostingDetailsQuery', _HASHES['StayHostingDetailsQuery'],
-        {'viewerTimeZoneOffset': 0, 'requestSource': 'RESERVATION_TAB', 'confirmationCode': c},
-        f'https://www.airbnb.com/hosting/reservations/details/{c}',
-      )
-      sections = (
-        resp.get('data', {}).get('presentation', {})
-            .get('hostingDetails', {}).get('stayHostingDetails', {})
-            .get('rootPlacement', [])
-      )
-      whoscoming = next((s for s in sections if s.get('sectionId') == 'WHOSCOMING_SECTION'), None)
-      wc_groups = (whoscoming or {}).get('sectionData', {}).get('reservationGroups', [])
-      wc_guests = wc_groups[0].get('guests', []) if wc_groups else []
-      facepile = next((s for s in sections if s.get('sectionId') == 'FACEPILE_SECTION'), None)
-      fp_guests = (facepile or {}).get('sectionData', {}).get('guests', [])
-      name = (wc_guests[0].get('name') if wc_guests else None) or (fp_guests[0].get('name') if fp_guests else None)
-
-      checkinout = next((s for s in sections if s.get('sectionId') == 'CHECK_IN_CHECK_OUT_SECTION'), None)
-      ci_out_data = (checkinout or {}).get('sectionData') or {}
-      action = ci_out_data.get('checkInAction', {})
-      ci_ts = action.get('checkInTime')
-      co_ts = action.get('checkOutTime')
-      ci = _utc_to_local_date(ci_ts) if ci_ts else None
-      co = _utc_to_local_date(co_ts) if co_ts else None
-      door_code = (ci_out_data.get('airkeyAction') or {}).get('guestPhoneNumberLastFourDigits')
-
-      # thread_id is in footerPlacement FLOATINGFOOTER_SECTION, not rootPlacement
-      details = resp.get('data', {}).get('presentation', {}).get('hostingDetails', {}).get('stayHostingDetails', {})
-      footer_sections = details.get('footerPlacement', [])
-      footer = next((s for s in footer_sections if s.get('sectionId') == 'FLOATINGFOOTER_SECTION'), None)
-      buttons = ((footer or {}).get('sectionData') or {}).get('buttons', [])
-      thread_id = None
-      for btn in buttons:
-        action = btn.get('buttonAction') or {}
-        if action.get('__typename') == 'NavigateToMessageGuest':
-          url = action.get('url', '')
-          m = __import__('re').search(r'/thread/(\d+)', url)
-          if m:
-            thread_id = m.group(1)
-            break
-
-      return c, name, ci, co, door_code, thread_id
+      async with _sem:
+        try:
+          resp = await self._gql(
+            'StayHostingDetailsQuery', _HASHES['StayHostingDetailsQuery'],
+            {'viewerTimeZoneOffset': 0, 'requestSource': 'RESERVATION_TAB', 'confirmationCode': c},
+            f'https://www.airbnb.com/hosting/reservations/details/{c}',
+          )
+        except httpx.TimeoutException:
+          _log.warning('airbnb: StayHostingDetailsQuery timed out for %s — skipping details', c)
+          return c, None, None, None, None, None
+        sections = (
+          resp.get('data', {}).get('presentation', {})
+              .get('hostingDetails', {}).get('stayHostingDetails', {})
+              .get('rootPlacement', [])
+        )
+        whoscoming = next((s for s in sections if s.get('sectionId') == 'WHOSCOMING_SECTION'), None)
+        wc_groups = (whoscoming or {}).get('sectionData', {}).get('reservationGroups', [])
+        wc_guests = wc_groups[0].get('guests', []) if wc_groups else []
+        facepile = next((s for s in sections if s.get('sectionId') == 'FACEPILE_SECTION'), None)
+        fp_guests = (facepile or {}).get('sectionData', {}).get('guests', [])
+        name = (wc_guests[0].get('name') if wc_guests else None) or (fp_guests[0].get('name') if fp_guests else None)
+
+        checkinout = next((s for s in sections if s.get('sectionId') == 'CHECK_IN_CHECK_OUT_SECTION'), None)
+        ci_out_data = (checkinout or {}).get('sectionData') or {}
+        action = ci_out_data.get('checkInAction', {})
+        ci_ts = action.get('checkInTime')
+        co_ts = action.get('checkOutTime')
+        ci = _utc_to_local_date(ci_ts) if ci_ts else None
+        co = _utc_to_local_date(co_ts) if co_ts else None
+        door_code = (ci_out_data.get('airkeyAction') or {}).get('guestPhoneNumberLastFourDigits')
+
+        # thread_id is in footerPlacement FLOATINGFOOTER_SECTION, not rootPlacement
+        det = resp.get('data', {}).get('presentation', {}).get('hostingDetails', {}).get('stayHostingDetails', {})
+        footer_sections = det.get('footerPlacement', [])
+        footer = next((s for s in footer_sections if s.get('sectionId') == 'FLOATINGFOOTER_SECTION'), None)
+        buttons = ((footer or {}).get('sectionData') or {}).get('buttons', [])
+        thread_id = None
+        for btn in buttons:
+          action = btn.get('buttonAction') or {}
+          if action.get('__typename') == 'NavigateToMessageGuest':
+            url = action.get('url', '')
+            m = __import__('re').search(r'/thread/(\d+)', url)
+            if m:
+              thread_id = m.group(1)
+              break
+
+        return c, name, ci, co, door_code, thread_id
 
     details_list = await asyncio.gather(*[fetch_details(c) for c in unique_codes])
     details = {c: (name, ci, co, door_code, thread_id) for c, name, ci, co, door_code, thread_id in details_list}