Просмотр исходного кода

Isolate per-service failures so one platform can't break others

ServiceAPI now catches all exceptions per-service at both startup
(from_env) and runtime (_gather). A failed or misconfigured service
is disabled rather than fatal, and surfaces in /status as
credentials.{key}.ok=false so monitors catch it.
ZiB 4 месяцев назад
Родитель
Сommit
15e1de9897
2 измененных файлов с 102 добавлено и 53 удалено
  1. 8 4
      room_cal/_health.py
  2. 94 49
      room_cal/service_api/client.py

+ 8 - 4
room_cal/_health.py

@@ -75,11 +75,15 @@ async def credentials_checks(api: ServiceAPI | None = None) -> dict[str, HealthR
   """Probe each configured service's live session."""
   if api is None:
     api = ServiceAPI()
+  out: dict[str, HealthResult] = {
+    key: HealthResult(ok=False, reason=f'disabled at startup: {reason}')
+    for key, reason in api._disabled.items()
+  }
   keys = [s.key for s in services() if s.key in api._clients]
-  if not keys:
-    return {}
-  results = await asyncio.gather(*[_probe_service(k, api) for k in keys])
-  return dict(zip(keys, results))
+  if keys:
+    results = await asyncio.gather(*[_probe_service(k, api) for k in keys])
+    out.update(zip(keys, results))
+  return out
 
 
 async def probe_once() -> None:

+ 94 - 49
room_cal/service_api/client.py

@@ -1,12 +1,14 @@
 import asyncio
+import logging
 from datetime import date
 
 from ._airbnb import _AirbnbClient
 from ._booking import _BookingClient
 from ._homestay import _HomestayClient
 from ._protocols import ChatClient, ServiceClient
-from .types import DayInfo
+from .types import DayInfo, ServiceAuthError
 
+_log = logging.getLogger(__name__)
 
 class ServiceAPI:
   """Unified async client for all booking platform services.
@@ -19,11 +21,21 @@ class ServiceAPI:
   """
 
   def __init__(self, clients: dict[str, ServiceClient] | None = None) -> None:
-    self._clients: dict[str, ServiceClient] = clients if clients is not None else {
-      'airbnb': _AirbnbClient.from_env(),
-      'booking': _BookingClient.from_env(),
-      'homestay': _HomestayClient.from_env(),
-    }
+    if clients is not None:
+      self._clients: dict[str, ServiceClient] = clients
+      return
+    self._clients = {}
+    self._disabled: dict[str, str] = {}
+    for key, factory in {
+      'airbnb': _AirbnbClient.from_env,
+      'booking': _BookingClient.from_env,
+      'homestay': _HomestayClient.from_env,
+    }.items():
+      try:
+        self._clients[key] = factory()
+      except Exception as e:
+        _log.warning('service %s disabled: %s', key, e)
+        self._disabled[key] = str(e)
 
   def client(self, key: str) -> ServiceClient:
     """Return the platform client for `key`, typed as ServiceClient."""
@@ -39,6 +51,19 @@ class ServiceAPI:
       raise TypeError(f'{key!r} does not support chat')
     return c  # type: ignore[return-value]
 
+  async def _gather(self, coros: dict[str, object]) -> dict[str, object]:
+    """Gather per-service results; isolate ServiceAuthError so one failed service can't kill others."""
+    keys = list(coros.keys())
+    raw = await asyncio.gather(*coros.values(), return_exceptions=True)  # type: ignore[arg-type]
+    out: dict[str, object] = {}
+    for key, result in zip(keys, raw):
+      if isinstance(result, BaseException):
+        _log.error('service %s error (skipping): %s', key, result, exc_info=result)
+        out[key] = None
+      else:
+        out[key] = result
+    return out
+
   async def get_prices_range(self, start_date: date, end_date: date) -> dict[str, dict[str, int | None]]:
     """Return {date_str: {service_key: price}} for all dates in the range.
 
@@ -48,79 +73,97 @@ class ServiceAPI:
     """
     ds_from = start_date.isoformat()
     ds_to = end_date.isoformat()
-    keys = list(self._clients.keys())
-    cals = await asyncio.gather(*[
-      client.get_calendar(ds_from, ds_to) for client in self._clients.values()
-    ])
+    cals = await self._gather({
+      key: client.get_calendar(ds_from, ds_to) for key, client in self._clients.items()
+    })
     all_dates: set[str] = set()
-    for cal in cals:
-      all_dates.update(d for d in cal if ds_from <= d <= ds_to)
+    for cal in cals.values():
+      if cal is not None:
+        all_dates.update(d for d in cal if ds_from <= d <= ds_to)  # type: ignore[union-attr]
     result: dict[str, dict[str, int | None]] = {}
     for d in sorted(all_dates):
-      result[d] = {key: cal[d].price if d in cal else None for key, cal in zip(keys, cals)}
+      result[d] = {
+        key: cal[d].price if (cal is not None and d in cal) else None  # type: ignore[index]
+        for key, cal in cals.items()
+      }
     return result
 
   async def get_prices(self, d: date) -> dict[str, int | None]:
     """Return {service_key: price} for all services on a single date."""
     ds = d.isoformat()
-    keys = list(self._clients.keys())
-    cals = await asyncio.gather(*[
-      client.get_calendar(ds, ds) for client in self._clients.values()
-    ])
-    return {key: cal[ds].price if ds in cal else None for key, cal in zip(keys, cals)}
+    cals = await self._gather({
+      key: client.get_calendar(ds, ds) for key, client in self._clients.items()
+    })
+    return {
+      key: cal[ds].price if (cal is not None and ds in cal) else None  # type: ignore[index]
+      for key, cal in cals.items()
+    }
 
   async def set_prices(self, d: date, prices: dict[str, int]) -> None:
     """Set price on each specified service; skips booked dates."""
     ds = d.isoformat()
     _fallback = DayInfo(date=ds, price=None, available=True, booked=False, rooms_to_sell=None)
-    cals = await asyncio.gather(*[self._clients[key].get_calendar(ds, ds) for key in prices])
+    cals = await self._gather({
+      key: self._clients[key].get_calendar(ds, ds) for key in prices if key in self._clients
+    })
     to_set = {
       key: price
-      for (key, price), cal in zip(prices.items(), cals)
-      if not cal.get(ds, _fallback).booked
+      for (key, price) in prices.items()
+      if cals.get(key) is not None and not cals[key].get(ds, _fallback).booked  # type: ignore[union-attr]
     }
     if to_set:
-      await asyncio.gather(*[self._clients[key].set_price(ds, price) for key, price in to_set.items()])
+      await self._gather({
+        key: self._clients[key].set_price(ds, price) for key, price in to_set.items()
+      })
 
   async def get_available(self, d: date) -> bool:
-    """Return True only when all services have the date open."""
+    """Return True only when all active (non-failed) services have the date open."""
     ds = d.isoformat()
-    keys = list(self._clients.keys())
-    cals = await asyncio.gather(*[
-      client.get_calendar(ds, ds) for client in self._clients.values()
-    ])
     _closed = DayInfo(date=ds, price=None, available=False, booked=False, rooms_to_sell=None)
-    return all(cal.get(ds, _closed).available for cal in cals)
+    cals = await self._gather({
+      key: client.get_calendar(ds, ds) for key, client in self._clients.items()
+    })
+    return all(
+      cal.get(ds, _closed).available  # type: ignore[union-attr]
+      for cal in cals.values() if cal is not None
+    )
 
   async def set_available(self, d: date, available: bool) -> None:
     """Open or block a date on all services; skips booked dates."""
     ds = d.isoformat()
     _fallback = DayInfo(date=ds, price=None, available=True, booked=False, rooms_to_sell=None)
-    keys = list(self._clients.keys())
-    cals = await asyncio.gather(*[client.get_calendar(ds, ds) for client in self._clients.values()])
-    to_set = [
-      client for key, client, cal in zip(keys, self._clients.values(), cals)
-      if not cal.get(ds, _fallback).booked
-    ]
+    cals = await self._gather({
+      key: client.get_calendar(ds, ds) for key, client in self._clients.items()
+    })
+    to_set = {
+      key: self._clients[key]
+      for key, cal in cals.items()
+      if cal is not None and not cal.get(ds, _fallback).booked  # type: ignore[union-attr]
+    }
     if to_set:
-      await asyncio.gather(*[client.set_availability(ds, available) for client in to_set])
+      await self._gather({
+        key: client.set_availability(ds, available) for key, client in to_set.items()
+      })
 
   async def get_closed_by_date(self, start_date: date, end_date: date) -> dict[str, list[str]]:
     """Return {date_str: [service_keys]} for dates blocked (not available, not booked) on at least one service."""
     ds_from = start_date.isoformat()
     ds_to = end_date.isoformat()
-    keys = list(self._clients.keys())
-    cals = await asyncio.gather(*[
-      client.get_calendar(ds_from, ds_to) for client in self._clients.values()
-    ])
+    cals = await self._gather({
+      key: client.get_calendar(ds_from, ds_to) for key, client in self._clients.items()
+    })
     all_dates: set[str] = set()
-    for cal in cals:
-      all_dates.update(cal.keys())
+    for cal in cals.values():
+      if cal is not None:
+        all_dates.update(cal.keys())  # type: ignore[union-attr]
     result: dict[str, list[str]] = {}
     for d in sorted(all_dates):
       if not (ds_from <= d <= ds_to):
         continue
-      closed = [key for key, cal in zip(keys, cals) if (day := cal.get(d)) and not day.available and not day.booked]
+      closed = [
+        key for key, cal in cals.items()
+        if cal is not None and (day := cal.get(d)) and not day.available and not day.booked  # type: ignore[union-attr]
+      ]
       if closed:
         result[d] = closed
     return result
@@ -130,14 +173,16 @@ class ServiceAPI:
 
     Each entry: {service, start_date, end_date, id, guest_name}
     """
-    keys = list(self._clients.keys())
-    all_res = await asyncio.gather(*[
-      client.get_reservations(start_date.isoformat(), end_date.isoformat())
-      for client in self._clients.values()
-    ])
+    ds_from = start_date.isoformat()
+    ds_to = end_date.isoformat()
+    all_res = await self._gather({
+      key: client.get_reservations(ds_from, ds_to) for key, client in self._clients.items()
+    })
     result: list[dict] = []
-    for key, reservations in zip(keys, all_res):
-      for r in reservations:
+    for key, reservations in all_res.items():
+      if reservations is None:
+        continue
+      for r in reservations:  # type: ignore[union-attr]
         result.append({
           'service': key,
           'start_date': date.fromisoformat(r.check_in),