mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-25 22:12:22 +02:00
105 lines
3.9 KiB
Python
105 lines
3.9 KiB
Python
"""Keeping this device usable while the cloud holds custody of a provider credential.
|
|
|
|
Handing a credential to the cloud strips the local refresh token on purpose: exactly one
|
|
holder may rotate it. 9Router's refresh dispatcher then bails on the falsy refreshToken
|
|
without calling the provider, so nothing renews the access token and every local call
|
|
starts failing a few hours after the handover, with no error that explains why.
|
|
|
|
This is the other half of that trade. The cloud rotates; this device asks the cloud for a
|
|
fresh access token shortly before the current one dies. Only lent connections are touched:
|
|
one that still holds a refresh token is 9Router's job and must be left alone.
|
|
|
|
Deliberately lazy. Each pull rewrites db.json, which means stopping the router and starting
|
|
it again, so we act only inside the margin and never on a fixed cadence.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import time
|
|
from typing import List, Optional
|
|
|
|
from typeguard import typechecked
|
|
|
|
from backend.apps.nine_router import credential_lease, credential_store
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Pull this far ahead of expiry. Wide enough that a failure leaves room for several retries
|
|
# before anything 401s, narrow enough that we are not restarting the router for fun.
|
|
REFRESH_MARGIN_S = 15 * 60
|
|
CHECK_INTERVAL_S = 120.0
|
|
# A dead network would otherwise mean a router restart every two minutes forever.
|
|
FAILURE_BACKOFF_S = 900.0
|
|
|
|
|
|
@typechecked
|
|
def p_seconds_left(expires_at: Optional[str]) -> Optional[float]:
|
|
"""Seconds until this token dies, or None when the timestamp is unreadable."""
|
|
ms = credential_lease.expires_ms(expires_at)
|
|
if ms <= 0:
|
|
return None
|
|
return (ms / 1000.0) - time.time()
|
|
|
|
|
|
@typechecked
|
|
def lent_connections_needing_a_pull() -> List[str]:
|
|
"""Connections the cloud holds whose access token is spent or nearly so.
|
|
|
|
A missing refresh token is what marks a connection as lent, and an unreadable expiry is
|
|
treated as due: better one wasted pull than a token that quietly stops working.
|
|
"""
|
|
due: List[str] = []
|
|
for connection_id in credential_store.list_oauth_connection_ids():
|
|
cred = credential_store.read_credential(connection_id)
|
|
if cred is None or cred.refresh_token:
|
|
continue
|
|
left = p_seconds_left(cred.expires_at)
|
|
if left is None or left <= REFRESH_MARGIN_S:
|
|
due.append(connection_id)
|
|
return due
|
|
|
|
|
|
# A pull rewrites db.json, which stops and restarts the router, so two of them racing the same
|
|
# connection is not just wasteful: it is two writers on one file, and one edit loses.
|
|
p_in_flight: set[str] = set()
|
|
|
|
|
|
@typechecked
|
|
async def refresh_lent_credentials() -> int:
|
|
"""Top up every lent connection that needs it. Returns how many are now good."""
|
|
refreshed = 0
|
|
for connection_id in lent_connections_needing_a_pull():
|
|
if connection_id in p_in_flight:
|
|
continue
|
|
p_in_flight.add(connection_id)
|
|
try:
|
|
outcome = await credential_lease.pull_access_token(connection_id)
|
|
finally:
|
|
p_in_flight.discard(connection_id)
|
|
if outcome.status == "refreshed":
|
|
refreshed += 1
|
|
continue
|
|
# Never the token itself, only why we could not get one.
|
|
logger.warning(
|
|
"could not renew the cloud-held credential %s: %s %s",
|
|
connection_id,
|
|
outcome.status,
|
|
outcome.detail,
|
|
)
|
|
return refreshed
|
|
|
|
|
|
@typechecked
|
|
async def lent_credential_loop() -> None:
|
|
while True:
|
|
delay = CHECK_INTERVAL_S
|
|
try:
|
|
due = lent_connections_needing_a_pull()
|
|
if due and await refresh_lent_credentials() == 0:
|
|
delay = FAILURE_BACKOFF_S
|
|
except Exception:
|
|
logger.exception("lent-credential refresh pass failed")
|
|
delay = FAILURE_BACKOFF_S
|
|
await asyncio.sleep(delay)
|