소스 검색

service_api: use the ServiceAPI singleton on request paths

Bare ServiceAPI() rebuilds every client from env per call, so the
per-instance month caches never carried across requests. Actions,
the chat route, the POST/PUT credential routes, and MCP now use
ServiceAPI.get().

Freshness paths that previously leaned on per-call reconstruction are
handled explicitly:
- ServiceAPI.refresh_credentials() now reloads the live client after
  the refresh subprocess rewrites the env file
- new ServiceAPI.reload_client(): in-place _reload_from_env for live
  clients, from_env rebuild for services that failed to load at
  startup (newly supplied credentials can enable them)
- PUT /credentials/{service} reloads the singleton's client after
  set_credentials

Health and credential-status probes deliberately keep constructing
fresh instances — a probe through the singleton would read its warm
calendar cache and report ok without touching the service.

tests: autouse fixture drops the singleton between tests.
ZiB 4 달 전
부모
커밋
33a875a19c

+ 3 - 1
room_cal/_health.py

@@ -66,6 +66,8 @@ async def _probe_service(key: str, api: ServiceAPI) -> HealthResult:
 async def credentials_checks(api: ServiceAPI | None = None) -> dict[str, HealthResult]:
   """Probe each configured service's live session."""
   if api is None:
+    # deliberately a fresh instance: probing through the singleton would read
+    # its warm calendar cache and report ok without touching the service
     api = ServiceAPI()
   out: dict[str, HealthResult] = {
     key: HealthResult(ok=False, reason=f'load error: {reason}')
@@ -83,7 +85,7 @@ async def probe_once() -> None:
   global _cached
   periodic_disabled = bool(os.getenv('DISABLE_PERIODIC'))
   try:
-    api = ServiceAPI()
+    api = ServiceAPI()  # fresh instance — see credentials_checks
     store_result = await store_check()
     creds = {} if periodic_disabled else await credentials_checks(api)
     store_ok = store_result.ok

+ 1 - 1
room_cal/actions/diff.py

@@ -56,7 +56,7 @@ async def diff(
   """
   from room_cal.service_api import ServiceAPI as _ServiceAPI
   if api is None:
-    api = _ServiceAPI()
+    api = _ServiceAPI.get()
   timings: dict[str, float] = {}
   t0 = time.perf_counter()
   results = await asyncio.gather(

+ 1 - 1
room_cal/actions/fetch_closed.py

@@ -26,7 +26,7 @@ async def fetch_closed(
   from room_cal.service_api import ServiceAPI as _ServiceAPI
   from room_cal.actions.fetch_reservations import fetch_reservations
   if api is None:
-    api = _ServiceAPI()
+    api = _ServiceAPI.get()
   closed_cal = Closed()
   res_cal = Reservation()
 

+ 1 - 1
room_cal/actions/fetch_prices.py

@@ -37,7 +37,7 @@ async def fetch_prices(
   """
   from room_cal.service_api import ServiceAPI as _ServiceAPI
   if api is None:
-    api = _ServiceAPI()
+    api = _ServiceAPI.get()
 
   api_prices, booked_nets = await asyncio.gather(
     api.get_prices_range(start_date, end_date),

+ 1 - 1
room_cal/actions/fetch_reservations.py

@@ -57,7 +57,7 @@ async def fetch_reservations(
   """
   from room_cal.service_api import ServiceAPI as _ServiceAPI
   if api is None:
-    api = _ServiceAPI()
+    api = _ServiceAPI.get()
   res_cal = Reservation()
   closed = Closed()
   all_service_keys = _services()

+ 1 - 1
room_cal/actions/push_closed.py

@@ -25,7 +25,7 @@ async def push_closed(
   """
   from room_cal.service_api import ServiceAPI as _ServiceAPI
   if api is None:
-    api = _ServiceAPI()
+    api = _ServiceAPI.get()
   closed_cal = Closed()
 
   cal_events = await closed_cal.list(start_date, end_date)

+ 1 - 1
room_cal/actions/push_prices.py

@@ -31,7 +31,7 @@ async def push_prices(
   """
   from room_cal.service_api import ServiceAPI as _ServiceAPI
   if api is None:
-    api = _ServiceAPI()
+    api = _ServiceAPI.get()
 
   backend = get_backend()
   events = await backend.fetch_range(

+ 1 - 1
room_cal/actions/remote_sync.py

@@ -23,5 +23,5 @@ async def remote_sync(
   """
   from room_cal.service_api import ServiceAPI as _ServiceAPI
   if api is None:
-    api = _ServiceAPI()
+    api = _ServiceAPI.get()
   return await api.remote_sync(start_date, end_date)

+ 1 - 1
room_cal/api/_routes/chat.py

@@ -33,7 +33,7 @@ def _normalize_thread(thread) -> dict:
 
 
 def _get_chat_client(service_key: str):
-  api = ServiceAPI()
+  api = ServiceAPI.get()
   try:
     return api.chat_client(service_key)
   except KeyError:

+ 5 - 2
room_cal/api/_routes/credentials.py

@@ -22,6 +22,8 @@ async def _probe(key: str, api: ServiceAPI) -> dict:
 
 @router.get('/credentials')
 async def get_credentials_status():
+  # deliberately a fresh instance: a probe through the singleton would read
+  # its warm calendar cache and report ok without touching the service
   api = ServiceAPI()
   keys = services()
   results = await asyncio.gather(*[_probe(k, api) for k in keys])
@@ -30,7 +32,7 @@ async def get_credentials_status():
 
 @router.get('/credentials/{service}')
 async def get_credential_status(service: str):
-  api = ServiceAPI()
+  api = ServiceAPI()  # fresh instance — see get_credentials_status
   if service not in api._clients:
     return check_credentials(service)
   return await _probe(service, api)
@@ -41,7 +43,7 @@ async def post_credentials_refresh(body: dict = Body(default={})):
   """Trigger automatic credential refresh for one service (body: {service: key}) or all services."""
   service = (body or {}).get('service')
   keys = [service] if service else services()
-  api = ServiceAPI()
+  api = ServiceAPI.get()
 
   async def _refresh(key: str) -> None:
     try:
@@ -56,4 +58,5 @@ async def post_credentials_refresh(body: dict = Body(default={})):
 @router.put('/credentials/{service}', status_code=204)
 async def put_credentials(service: str, credentials: dict[str, str] = Body(...)):
   set_credentials(service, credentials)
+  ServiceAPI.get().reload_client(service)
   asyncio.create_task(probe_once())

+ 3 - 3
room_cal/mcp/__init__.py

@@ -182,7 +182,7 @@ async def refresh_credentials(service: str | None = None) -> dict:
   from room_cal.service_api import ServiceAPI
   from room_cal.service_api.types import ServiceAuthError
   keys = [service] if service else _services()
-  api = ServiceAPI()
+  api = ServiceAPI.get()
 
   async def _do(key: str) -> tuple[str, str | None]:
     try:
@@ -211,7 +211,7 @@ async def get_chat_thread(service_key: str, chat_id: str) -> dict:
   """Fetch recent messages for a reservation thread. Returns {chat_id, messages: [{id, created_at, body, sender}], is_reply_needed?}."""
   from room_cal.service_api import ServiceAPI
   from room_cal.service_api.types import ServiceAuthError
-  api = ServiceAPI()
+  api = ServiceAPI.get()
   try:
     client = api.chat_client(service_key)
   except KeyError:
@@ -245,7 +245,7 @@ async def send_chat_message(service_key: str, chat_id: str, body: str) -> None:
   """Send a text message to a guest conversation thread."""
   from room_cal.service_api import ServiceAPI
   from room_cal.service_api.types import ServiceAuthError
-  api = ServiceAPI()
+  api = ServiceAPI.get()
   try:
     client = api.chat_client(service_key)
   except KeyError:

+ 22 - 0
room_cal/service_api/client.py

@@ -72,6 +72,28 @@ class ServiceAPI:
       raise ServiceAuthError(f'No automatic refresh handler for service: {key}')
     loop = asyncio.get_running_loop()
     await loop.run_in_executor(None, cls.refresh_credentials)
+    self.reload_client(key)
+
+  def reload_client(self, key: str) -> None:
+    """Pick up externally updated credentials (env file rewritten by a refresh or PUT).
+
+    A live client reloads in place; a service that failed to load at startup is
+    rebuilt from env so newly supplied credentials can enable it.
+    """
+    client = self._clients.get(key)
+    if client is not None and hasattr(client, '_reload_from_env'):
+      client._reload_from_env()
+      return
+    cls = self._CLIENT_CLASSES.get(key)
+    if cls is None:
+      return
+    try:
+      self._clients[key] = cls.from_env()
+      self._disabled.pop(key, None)
+    except Exception as e:
+      _log.warning('service %s failed to reload: %s', key, e)
+      self._clients.pop(key, None)
+      self._disabled[key] = str(e)
 
   def client(self, key: str) -> ServiceClient:
     """Return the platform client for `key`, typed as ServiceClient."""

+ 8 - 0
tests/conftest.py

@@ -23,6 +23,14 @@ def pytest_collection_modifyitems(config, items):
       item.add_marker(skip_live)
 
 
+@pytest.fixture(autouse=True)
+def _reset_service_api_singleton():
+  """Drop the ServiceAPI singleton after each test so stubs and creds can't leak."""
+  yield
+  import room_cal.service_api.client as _client
+  _client._instance = None
+
+
 def _free_port() -> int:
   with socket.socket() as s:
     s.bind(('127.0.0.1', 0))