Skip to content

Commit d99f37e

Browse files
authored
Refresh OverDrive collection token every 30 days (PP-3937) (#3204)
## Description Add a two-layer cache for OverDrive collection tokens so they are automatically refreshed as OverDrive rotates them, without requiring a process restart or configuration change. ## Motivation and Context OverDrive is retiring legacy collection tokens within the next 3–4 months and moving to a model where tokens must be refreshed periodically. `collectionToken` is embedded in the library account response (`GET /v1/libraries/{id}`) and is required for all collection-scoped API calls (search, product listings, availability, metadata). Two problems existed in the prior implementation: 1. **DB cache had no TTL.** `get_library()` and `get_advantage_accounts()` called `Representation.get()` with no `max_age`, so the library document (and the `collectionToken` within it) could persist in the `representations` table indefinitely. 2. **In-memory cache was unbounded.** `_collection_token` was a plain `str | None` set once and never cleared. Flask workers hold one `OverdriveAPI` instance per collection for the entire process lifetime (`CirculationManager.load_settings`), so a rotated token would never be picked up without a full process restart or an admin config change. ### Solution: two-layer cache | Layer | Constant | TTL | Purpose | |---|---|---|---| | In-memory (`_cached_collection_token`) | `COLLECTION_TOKEN_MAX_AGE` | 5 minutes | Avoids a DB hit on every request | | DB (`representations` table) | `LIBRARY_MAX_AGE` | 30 days | Avoids a network hit on every 5-minute miss | A long-lived Flask worker re-checks the DB every 5 minutes and the DB re-fetches from OverDrive every 30 days. Rotated tokens are picked up within 5 minutes. ## How Has This Been Tested? - `test_collection_token` — verifies the in-memory cache is populated on first access and reused on subsequent calls within `COLLECTION_TOKEN_MAX_AGE`. - `test_collection_token_cache_expires` — verifies that aging the in-memory cache past `COLLECTION_TOKEN_MAX_AGE` causes `get_library` to be called again and returns the new token. - `test_collection_token_error` — verifies that an `errorCode` in the library response raises `CannotLoadConfiguration`. - `test_get_library_passes_max_age` — verifies `LIBRARY_MAX_AGE` is forwarded to `Representation.get()` in `get_library()`. - `test_get_library_refreshes_stale_token` — end-to-end DB staleness test: ages the `Representation` record past `LIBRARY_MAX_AGE` and verifies the next call re-fetches from the network. - `test_get_advantage_accounts_passes_max_age` — verifies `LIBRARY_MAX_AGE` is also forwarded in the advantage accounts `Representation.get()` call. ## Checklist - [x] I have updated the documentation accordingly. - [x] All new and existing tests passed.
1 parent 0312188 commit d99f37e

4 files changed

Lines changed: 111 additions & 28 deletions

File tree

src/palace/manager/integration/license/overdrive/api.py

Lines changed: 54 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,19 @@ class OverdriveAPI(
241241

242242
EVENT_DELAY = datetime.timedelta(minutes=120)
243243

244+
# Maximum age of the cached library document before it is re-fetched
245+
# from the OverDrive API. OverDrive periodically rotates collection tokens,
246+
# so this ensures the representations table cache doesn't serve a stale
247+
# token indefinitely.
248+
LIBRARY_MAX_AGE: datetime.timedelta = datetime.timedelta(days=30)
249+
250+
# How long to keep the collection token in the in-memory cache before
251+
# re-checking the representations table. Kept short so that long-lived
252+
# Flask worker processes (which hold one OverdriveAPI instance per
253+
# collection for the process lifetime) pick up rotated tokens within a
254+
# reasonable window without hitting the DB on every request.
255+
COLLECTION_TOKEN_MAX_AGE: datetime.timedelta = datetime.timedelta(minutes=5)
256+
244257
TIME_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
245258

246259
NEXT_REL = "next"
@@ -309,8 +322,11 @@ def __init__(self, _db: Session, collection: Collection) -> None:
309322
# This is set by access to ._client_oauth_token
310323
self._cached_client_oauth_token: OverdriveToken | None = None
311324

312-
# This is set by access to .collection_token
313-
self._collection_token: str | None = None
325+
# In-memory cache for the collectionToken extracted from the library
326+
# document. Expires after COLLECTION_TOKEN_MAX_AGE so that long-lived
327+
# instances (e.g. Flask workers) transparently re-fetch a rotated token
328+
# without a process restart. See the collection_token property.
329+
self._cached_collection_token: OverdriveToken | None = None
314330
self.overdrive_bibliographic_coverage_provider = (
315331
OverdriveBibliographicCoverageProvider(collection, api=self)
316332
)
@@ -381,22 +397,33 @@ def _refresh_client_oauth_token(self) -> OverdriveToken:
381397
def collection_token(self) -> str:
382398
"""Get the token representing this particular Overdrive collection.
383399
384-
As a side effect, this will verify that the Overdrive
400+
The token is cached in memory for :attr:`COLLECTION_TOKEN_MAX_AGE`
401+
(5 minutes) to avoid a DB lookup on every request. Once that expires,
402+
:meth:`get_library` is called, which uses the ``representations`` table
403+
as a second cache layer refreshed at most once every
404+
:attr:`LIBRARY_MAX_AGE` (30 days).
405+
406+
As a side effect of a cache miss, this verifies that the OverDrive
385407
credentials are working.
386408
"""
387-
collection_token = self._collection_token
388-
if not collection_token:
389-
library = self.get_library()
390-
error = library.get("errorCode")
391-
if error:
392-
message = library.get("message")
393-
raise CannotLoadConfiguration(
394-
"Overdrive credentials are valid but could not fetch library: %s"
395-
% message
396-
)
397-
collection_token = cast(str, library["collectionToken"])
398-
self._collection_token = collection_token
399-
return collection_token
409+
cached = self._cached_collection_token
410+
if cached is not None and utc_now() < cached.expires:
411+
return cached.token
412+
413+
library = self.get_library()
414+
error = library.get("errorCode")
415+
if error:
416+
message = library.get("message")
417+
raise CannotLoadConfiguration(
418+
"Overdrive credentials are valid but could not fetch library: %s"
419+
% message
420+
)
421+
token = cast(str, library["collectionToken"])
422+
self._cached_collection_token = OverdriveToken(
423+
token=token,
424+
expires=utc_now() + self.COLLECTION_TOKEN_MAX_AGE,
425+
)
426+
return token
400427

401428
@property
402429
def data_source(self) -> DataSource:
@@ -531,6 +558,11 @@ def _library_endpoint(self) -> str:
531558
def get_library(self) -> dict[str, Any]:
532559
"""Get basic information about the collection, including
533560
a link to the titles in the collection.
561+
562+
The response is cached in the ``representations`` table and refreshed
563+
at most once every :attr:`LIBRARY_MAX_AGE`. This ensures the
564+
``collectionToken`` embedded in the response stays current, since
565+
OverDrive periodically rotates collection tokens.
534566
"""
535567
url = self._library_endpoint
536568
with self.lock:
@@ -539,12 +571,17 @@ def get_library(self) -> dict[str, Any]:
539571
url,
540572
self.get,
541573
exception_handler=Representation.reraise_exception,
574+
max_age=self.LIBRARY_MAX_AGE,
542575
)
543576
return json.loads(representation.content) # type: ignore[no-any-return]
544577

545578
def get_advantage_accounts(self) -> Generator[OverdriveAdvantageAccount]:
546579
"""Find all the Overdrive Advantage accounts managed by this library.
547580
581+
The advantage accounts response is cached in the ``representations``
582+
table and refreshed at most once every :attr:`LIBRARY_MAX_AGE`,
583+
matching the same cadence as :meth:`get_library`.
584+
548585
:yield: A sequence of OverdriveAdvantageAccount objects.
549586
"""
550587
library = self.get_library()
@@ -561,6 +598,7 @@ def get_advantage_accounts(self) -> Generator[OverdriveAdvantageAccount]:
561598
advantage_url,
562599
self.get,
563600
exception_handler=Representation.reraise_exception,
601+
max_age=self.LIBRARY_MAX_AGE,
564602
)
565603
yield from OverdriveAdvantageAccount.from_representation(
566604
representation.content

tests/manager/integration/license/overdrive/test_api.py

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@
2727
from palace.manager.api.config import Configuration
2828
from palace.manager.core.config import CannotLoadConfiguration
2929
from palace.manager.core.exceptions import BasePalaceException, IntegrationException
30-
from palace.manager.integration.license.overdrive.api import OverdriveAPI
30+
from palace.manager.integration.license.overdrive.api import (
31+
OverdriveAPI,
32+
OverdriveToken,
33+
)
3134
from palace.manager.integration.license.overdrive.constants import OverdriveConstants
3235
from palace.manager.integration.license.overdrive.exception import (
3336
OverdriveValidationError,
@@ -1548,20 +1551,53 @@ def test_collection_token(self, db: DatabaseTransactionFixture) -> None:
15481551
mock_get_library = MagicMock(return_value={"collectionToken": "abc"})
15491552
api.get_library = mock_get_library
15501553

1551-
# If the collection token is set, we just return that
1552-
api._collection_token = "123"
1553-
assert api.collection_token == "123"
1554-
mock_get_library.assert_not_called()
1555-
1556-
# If its not we get it from the get_library method
1557-
api._collection_token = None
1554+
# Cache is empty on first access — get_library is called.
1555+
assert api._cached_collection_token is None
15581556
assert api.collection_token == "abc"
15591557
mock_get_library.assert_called_once()
15601558

1561-
# Calling again returns the cached value
1559+
# Subsequent calls within COLLECTION_TOKEN_MAX_AGE return the cached
1560+
# value without calling get_library again.
15621561
assert api.collection_token == "abc"
15631562
mock_get_library.assert_called_once()
15641563

1564+
def test_collection_token_cache_expires(
1565+
self, db: DatabaseTransactionFixture
1566+
) -> None:
1567+
"""After COLLECTION_TOKEN_MAX_AGE has elapsed the in-memory cache is
1568+
bypassed and get_library is called again to pick up a rotated token."""
1569+
api = OverdriveAPI(db.session, db.collection(protocol=OverdriveAPI))
1570+
mock_get_library = MagicMock(
1571+
side_effect=[
1572+
{"collectionToken": "old-token"},
1573+
{"collectionToken": "new-token"},
1574+
]
1575+
)
1576+
api.get_library = mock_get_library
1577+
1578+
assert api.collection_token == "old-token"
1579+
mock_get_library.assert_called_once()
1580+
1581+
# Age the in-memory cache past COLLECTION_TOKEN_MAX_AGE.
1582+
assert api._cached_collection_token is not None
1583+
api._cached_collection_token = OverdriveToken(
1584+
token=api._cached_collection_token.token,
1585+
expires=utc_now() - timedelta(seconds=1),
1586+
)
1587+
1588+
# The next access should bypass the cache and call get_library again.
1589+
assert api.collection_token == "new-token"
1590+
assert mock_get_library.call_count == 2
1591+
1592+
def test_collection_token_error(self, db: DatabaseTransactionFixture) -> None:
1593+
"""An errorCode in the library response raises CannotLoadConfiguration."""
1594+
api = OverdriveAPI(db.session, db.collection(protocol=OverdriveAPI))
1595+
api.get_library = MagicMock(
1596+
return_value={"errorCode": "NotFound", "message": "bad credentials"}
1597+
)
1598+
with pytest.raises(CannotLoadConfiguration, match="bad credentials"):
1599+
api.collection_token
1600+
15651601
def test_circulation_lookup(
15661602
self, overdrive_api_fixture: OverdriveAPIFixture, db: DatabaseTransactionFixture
15671603
):

tests/manager/integration/license/overdrive/test_script.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,25 @@
22

33
import csv
44
import os
5+
from datetime import timedelta
56
from unittest.mock import MagicMock, Mock, patch
67

78
import pytest
89

910
from palace.manager.integration.license.overdrive.advantage import (
1011
OverdriveAdvantageAccount,
1112
)
12-
from palace.manager.integration.license.overdrive.api import OverdriveAPI
13+
from palace.manager.integration.license.overdrive.api import (
14+
OverdriveAPI,
15+
OverdriveToken,
16+
)
1317
from palace.manager.integration.license.overdrive.script import (
1418
GenerateOverdriveAdvantageAccountList,
1519
ImportCollection,
1620
ImportCollectionGroup,
1721
)
1822
from palace.manager.sqlalchemy.model.collection import Collection
23+
from palace.manager.util.datetime_helpers import utc_now
1924
from tests.fixtures.database import DatabaseTransactionFixture
2025
from tests.fixtures.overdrive import OverdriveAPIFixture
2126

@@ -70,7 +75,9 @@ def test_generate_od_advantage_account_list(
7075
),
7176
]
7277
overdrive_api.get_advantage_accounts = mock_get_advantage_accounts
73-
overdrive_api._collection_token = library_token
78+
overdrive_api._cached_collection_token = OverdriveToken(
79+
library_token, utc_now() + timedelta(hours=1)
80+
)
7481

7582
with patch.object(
7683
GenerateOverdriveAdvantageAccountList, "_create_overdrive_api"

tests/mocks/overdrive.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ def __init__(self, _db: Session, collection: Collection) -> None:
1919

2020
# Initialize some variables that are normally set when they are first accessed,
2121
# since most tests will access them.
22-
self._collection_token = "fake collection token"
22+
self._cached_collection_token = OverdriveToken(
23+
"fake collection token", utc_now() + timedelta(hours=1)
24+
)
2325
self._cached_client_oauth_token = OverdriveToken(
2426
"fake client oauth token", utc_now() + timedelta(hours=1)
2527
)

0 commit comments

Comments
 (0)