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.
This commit is contained in:
Oliver Falk
2025-10-16 11:37:47 +02:00
parent 844aca54a0
commit 8b04c170ec
4 changed files with 140 additions and 13 deletions

View File

@@ -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"

View File

@@ -398,9 +398,28 @@ class ConfirmedEmail(BaseAccountModel):
)
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 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):

View File

@@ -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

View File

@@ -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()
except Exception as exc:
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 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):
"""