Files
openswarm/backend/apps/nine_router/lent_credential_refresh.py
T

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)