From 8b04c170eca9b10ebacf8dce9a1298b4be7ca6b1 Mon Sep 17 00:00:00 2001 From: Oliver Falk Date: Thu, 16 Oct 2025 11:37:47 +0200 Subject: [PATCH] Fix Bluesky integration caching and API session management - Fix stale cache issue: assignment pages now show updated data immediately - Implement persistent session management to reduce createSession API calls - Add robust error handling for cache operations when Memcached unavailable - Eliminate code duplication in get_profile method with _make_profile_request - Add Bluesky credentials configuration to config_local.py.example Resolves caching problems and API rate limiting issues in development and production. --- config_local.py.example | 4 ++ ivatar/ivataraccount/models.py | 55 ++++++++++++++- ivatar/ivataraccount/test_views_bluesky.py | 12 +++- ivatar/utils.py | 82 +++++++++++++++++++--- 4 files changed, 140 insertions(+), 13 deletions(-) diff --git a/config_local.py.example b/config_local.py.example index df34063..5b77766 100644 --- a/config_local.py.example +++ b/config_local.py.example @@ -44,3 +44,7 @@ import os # Example: Override logs directory for production # LOGS_DIR = "/var/log/ivatar" + +# Bluesky integration credentials +# BLUESKY_IDENTIFIER = "your-bluesky-handle" +# BLUESKY_APP_PASSWORD = "your-app-password" diff --git a/ivatar/ivataraccount/models.py b/ivatar/ivataraccount/models.py index 3af7c5f..61a6487 100644 --- a/ivatar/ivataraccount/models.py +++ b/ivatar/ivataraccount/models.py @@ -398,9 +398,28 @@ class ConfirmedEmail(BaseAccountModel): ) cache_key = f"views.decorators.cache.cache_page.{quote(str(cache_url))}" - if cache.has_key(cache_key): - cache.delete(cache_key) - logger.debug("Successfully cleaned up cached page: %s" % cache_key) + try: + if cache.has_key(cache_key): + cache.delete(cache_key) + logger.debug("Successfully cleaned up cached page: %s" % cache_key) + except Exception as exc: + logger.warning( + "Failed to clean up cached page %s: %s" % (cache_key, exc) + ) + + # Invalidate Bluesky avatar URL cache if bluesky_handle changed + if hasattr(self, "bluesky_handle") and self.bluesky_handle: + try: + cache.delete(self.bluesky_handle) + logger.debug( + "Successfully cleaned up Bluesky avatar cache for handle: %s" + % self.bluesky_handle + ) + except Exception as exc: + logger.warning( + "Failed to clean up Bluesky avatar cache for handle %s: %s" + % (self.bluesky_handle, exc) + ) return super().save(force_insert, force_update, using, update_fields) @@ -570,6 +589,36 @@ class ConfirmedOpenId(BaseAccountModel): openid_variations(lowercase_url)[3].encode("utf-8") ).hexdigest() + # Invalidate page caches and Bluesky avatar cache + if self.pk: + # Invalidate assign_photo_openid page cache + cache_url = reverse_lazy( + "assign_photo_openid", kwargs={"openid_id": int(self.pk)} + ) + cache_key = f"views.decorators.cache.cache_page.{quote(str(cache_url))}" + try: + if cache.has_key(cache_key): + cache.delete(cache_key) + logger.debug("Successfully cleaned up cached page: %s" % cache_key) + except Exception as exc: + logger.warning( + "Failed to clean up cached page %s: %s" % (cache_key, exc) + ) + + # Invalidate Bluesky avatar URL cache if bluesky_handle exists + if hasattr(self, "bluesky_handle") and self.bluesky_handle: + try: + cache.delete(self.bluesky_handle) + logger.debug( + "Successfully cleaned up Bluesky avatar cache for handle: %s" + % self.bluesky_handle + ) + except Exception as exc: + logger.warning( + "Failed to clean up Bluesky avatar cache for handle %s: %s" + % (self.bluesky_handle, exc) + ) + return super().save(force_insert, force_update, using, update_fields) def __str__(self): diff --git a/ivatar/ivataraccount/test_views_bluesky.py b/ivatar/ivataraccount/test_views_bluesky.py index 2f64e1a..0011737 100644 --- a/ivatar/ivataraccount/test_views_bluesky.py +++ b/ivatar/ivataraccount/test_views_bluesky.py @@ -23,7 +23,7 @@ django.setup() # pylint: disable=wrong-import-position from ivatar import settings from ivatar.ivataraccount.models import ConfirmedOpenId, ConfirmedEmail -from ivatar.utils import random_string +from ivatar.utils import random_string, Bluesky from libravatar import libravatar_url @@ -63,6 +63,16 @@ class Tester(TestCase): # pylint: disable=too-many-public-methods ) settings.EMAIL_BACKEND = "django.core.mail.backends.dummy.EmailBackend" + # Clear any existing Bluesky session to ensure clean test state + Bluesky.clear_shared_session() + + def tearDown(self): + """ + Clean up after tests + """ + # Clear Bluesky session to avoid affecting other tests + Bluesky.clear_shared_session() + def create_confirmed_openid(self): """ Create a confirmed openid diff --git a/ivatar/utils.py b/ivatar/utils.py index 8252234..dc950d2 100644 --- a/ivatar/utils.py +++ b/ivatar/utils.py @@ -36,13 +36,15 @@ def urlopen(url, timeout=URL_TIMEOUT): class Bluesky: """ - Handle Bluesky client access + Handle Bluesky client access with persistent session management """ identifier = "" app_password = "" service = "https://bsky.social" session = None + _shared_session = None # Class-level shared session + _session_expires_at = None # Track session expiration def __init__( self, @@ -54,10 +56,29 @@ class Bluesky: self.app_password = app_password self.service = service + def _is_session_valid(self) -> bool: + """ + Check if the current session is still valid + """ + if not self._shared_session or not self._session_expires_at: + return False + + import time + + # Add 5 minute buffer before actual expiration + return time.time() < (self._session_expires_at - 300) + def login(self): """ - Login to Bluesky + Login to Bluesky with session persistence """ + # Use shared session if available and valid + if self._is_session_valid(): + self.session = self._shared_session + logger.debug("Reusing existing Bluesky session") + return + + logger.debug("Creating new Bluesky session") auth_response = requests.post( f"{self.service}/xrpc/com.atproto.server.createSession", json={"identifier": self.identifier, "password": self.app_password}, @@ -65,6 +86,29 @@ class Bluesky: auth_response.raise_for_status() self.session = auth_response.json() + # Store session data for reuse + self._shared_session = self.session + import time + + # Sessions typically expire in 24 hours, but we'll refresh every 12 hours + self._session_expires_at = time.time() + (12 * 60 * 60) + + logger.debug( + "Created new Bluesky session, expires at: %s", + time.strftime( + "%Y-%m-%d %H:%M:%S", time.localtime(self._session_expires_at) + ), + ) + + @classmethod + def clear_shared_session(cls): + """ + Clear the shared session (useful for testing) + """ + cls._shared_session = None + cls._session_expires_at = None + logger.debug("Cleared shared Bluesky session") + def normalize_handle(self, handle: str) -> str: """ Return the normalized handle for given handle @@ -79,11 +123,10 @@ class Bluesky: handle = handle[:-1] return handle - def get_profile(self, handle: str) -> str: - if not self.session: - self.login() - profile_response = None - + def _make_profile_request(self, handle: str): + """ + Make a profile request to Bluesky API with automatic retry on session expiration + """ try: profile_response = requests.get( f"{self.service}/xrpc/app.bsky.actor.getProfile", @@ -91,11 +134,32 @@ class Bluesky: params={"actor": handle}, ) profile_response.raise_for_status() + return profile_response.json() + except requests.exceptions.HTTPError as exc: + if exc.response.status_code == 401: + # Session expired, try to login again + logger.warning("Bluesky session expired, re-authenticating") + self.clear_shared_session() + self.login() + # Retry the request + profile_response = requests.get( + f"{self.service}/xrpc/app.bsky.actor.getProfile", + headers={"Authorization": f'Bearer {self.session["accessJwt"]}'}, + params={"actor": handle}, + ) + profile_response.raise_for_status() + return profile_response.json() + else: + logger.warning(f"Bluesky profile fetch failed with HTTP error: {exc}") + return None except Exception as exc: - logger.warning(f"Bluesky profile fetch failed with HTTP error: {exc}") + logger.warning(f"Bluesky profile fetch failed with error: {exc}") return None - return profile_response.json() + def get_profile(self, handle: str) -> str: + if not self.session or not self._is_session_valid(): + self.login() + return self._make_profile_request(handle) def get_avatar(self, handle: str): """