Skip to content

Move async book-info fetching into its own request class (PP-4938) - #3683

Open
jonathangreen wants to merge 8 commits into
chore/overdrive-patron-requestsfrom
chore/overdrive-async-move
Open

Move async book-info fetching into its own request class (PP-4938)#3683
jonathangreen wants to merge 8 commits into
chore/overdrive-patron-requestsfrom
chore/overdrive-async-move

Conversation

@jonathangreen

@jonathangreen jonathangreen commented Aug 31, 2026

Copy link
Copy Markdown
Member

Description

Moves fetch_book_info_list and its helpers, along with the BookInfoEndpoint dataclass, out of OverdriveAPI and into a new OverdriveAsyncRequests. The importer and the celery task are updated in the same change, so there is no temporary re-export.

Also drops the extractor_class parameter from fetch_book_info_list.

Motivation and Context

Part of separating the Overdrive integration's HTTP concerns from its business logic (PP-4938).

The async page fetching bypassed the token and endpoint machinery it sits next to, so it belongs in the transport layer rather than in the API class.

It gets its own class rather than joining OverdriveClientRequests, because it shares almost none of that class's machinery. It runs on httpx rather than requests, with its own retry and backoff policy, and it holds a single client open across a whole page of products. What it does share is the bearer token, which it reaches through the OverdriveClientRequests it is handed:

def _create_configured_async_client(self, base_url: str) -> AsyncClient:
    return AsyncClient.for_worker(
        base_url=base_url,
        headers=self._client_requests.auth_headers(),
        ...
    )

so a collection still only ever holds one token.

A later PR changes how we get the token, so that we can handle token refreshes on the async path, but since this change is mostly a mechanical move, I'm going to keep that to a separate PR.

The extractor_class parameter is removed rather than carried along: no caller ever passed it, and the only thing it was used for was finding the next-page link. That is now a small private helper using _make_link_safe directly. Keeping the parameter would have meant importing OverdriveRepresentationExtractor into the transport layer, and with it the data layer and SQLAlchemy models behind it, which is exactly the coupling this series is removing. A later PR replaces the helper with a typed model method.

How Has This Been Tested?

The async tests move from test_api.py to test_requests.py as TestOverdriveAsyncRequests, and now build a BookInfoEndpoint directly rather than going through the database-backed book_info_initial_endpoint, so they exercise the transport layer without a session. They continue to use MockAsyncClientFixture, and cover the happy path, retry-then-fail on a server error, tolerated 404s on metadata and availability links, a malformed response missing products, and an empty collection.

A new overdrive_async_requests fixture builds the class against a client context with a seeded token, which replaces the autouse token-seeding the old tests needed.

tox -e py312-docker -- tests/manager/

5989 passed. mypy clean.

Checklist

  • I have updated the documentation accordingly.
  • All new and existing tests passed.

@claude

claude Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Claude finished @jonathangreen's task in 4m 57s —— View job


Code Review

  • Gather context (diff, changed files)
  • Review requests.py (new OverdriveAsyncRequests)
  • Review api.py / importer.py / celery task changes
  • Review test changes and fixtures
  • Post review

Summary

The move is faithful: comparing the old OverdriveAPI methods against the new OverdriveAsyncRequests, the only behavioral deltas are extractor_class.link_page_link (byte-for-byte equivalent to OverdriveRepresentationExtractor.link, both _make_link_safe the href) and host resolution now coming from the new class's own settings instead of client_requests.endpoint, which resolves identically for a given collection since OverdriveAPI builds both from self._settings. I found no correctness issues; the only two remaining fetch_book_info_list callers (importer.py:259, test_importer.py) and every BookInfoEndpoint import were updated, no caller passed extractor_class, and the removed HOST_ENDPOINT_BASE class attribute has no remaining external references. The relocated tests are stronger than the ones they replace — "id" in metadata / "copiesOwned" in availabilityV2 actually discriminates the two responses, where the old truthiness assertions would have passed with them swapped. I could not run the suite or mypy here (no dependencies or database in this environment), so the reported results are unverified by me.

Details

Minor: src/palace/manager/integration/license/overdrive/requests.py:355-361

OverdriveAsyncRequests takes settings only to re-derive hosts through BaseOverdriveRequests, even though the client_requests it is also handed has already resolved the same hosts from the same settings — so the base URL and the bearer token come from two independently-supplied sources that OverdriveAPI has to keep in sync. It also inherits _do_get/_do_post/REQUEST_TIMEOUT, which the docstring correctly says it shares none of. Since it already holds the collaborator, a single source of truth is available:

def __init__(self, client_requests: OverdriveClientRequests) -> None:
    self._client_requests = client_requests
...
base_url = self._client_requests.endpoint(HOST_ENDPOINT_BASE)

def __init__(
self,
settings: OverdriveSettings,
client_requests: OverdriveClientRequests,
) -> None:
super().__init__(settings)
self._client_requests = client_requests

Nit: src/palace/manager/integration/license/overdrive/requests.py:60-63

The comment says the constant is module level "so both request classes can build their templates from it", but only OverdriveClientRequests builds templates from it; OverdriveAsyncRequests passes it to endpoint() at runtime (line 384) and would work fine with an inherited class attribute. Worth trimming to the reason that actually forces module scope — that OverdriveClientRequests's own class body needs it, and a base-class attribute is not visible there.

# The host portion of every client-context URL template. Module level so both
# request classes can build their templates from it; a class attribute on the
# base would not be visible inside a subclass's own class body.
HOST_ENDPOINT_BASE = "%(host)s"

| Branch: chore/overdrive-async-move

@greptile-apps

greptile-apps Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR moves asynchronous OverDrive book-information fetching from the API class into a dedicated request class while preserving the importer’s paginated workflow.

  • Adds OverdriveAsyncRequests and relocates BookInfoEndpoint.
  • Updates importer and Celery integrations to use the new request boundary.
  • Moves and adapts the asynchronous transport tests.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
src/palace/manager/integration/license/overdrive/requests.py Introduces the dedicated asynchronous request class while retaining the prior page, relation-fetching, retry, and link-sanitization behavior.
src/palace/manager/integration/license/overdrive/api.py Composes the new asynchronous request object and removes transport-specific methods from the API class.
src/palace/manager/integration/license/overdrive/importer.py Redirects paginated book-information fetching through the new request object without changing importer flow.
src/palace/manager/celery/tasks/overdrive.py Updates the relocated endpoint type import used by OverDrive task orchestration.
tests/manager/integration/license/overdrive/test_requests.py Moves asynchronous transport coverage to the request-layer test suite and retains success, retry, malformed-response, 404, pagination, and empty-page cases.

Sequence Diagram

sequenceDiagram
    participant Worker as Celery import worker
    participant Importer as OverdriveImporter
    participant API as OverdriveAPI
    participant Async as OverdriveAsyncRequests
    participant OD as OverDrive
    Worker->>Importer: Import collection page
    Importer->>API: Build initial endpoint
    Importer->>Async: fetch_book_info_list(endpoint)
    Async->>OD: Fetch product page
    par Linked metadata
        Async->>OD: Fetch metadata
    and Linked availability
        Async->>OD: Fetch availability
    end
    Async-->>Importer: Products and next endpoint
    Importer-->>Worker: Import result and continuation
Loading

Reviews (10): Last reviewed commit: "Say why the host template is module leve..." | Re-trigger Greptile

@codecov

codecov Bot commented Aug 31, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.57%. Comparing base (0f7432a) to head (04eafce).

Additional details and impacted files
@@                       Coverage Diff                        @@
##           chore/overdrive-patron-requests    #3683   +/-   ##
================================================================
  Coverage                            93.57%   93.57%           
================================================================
  Files                                  514      514           
  Lines                                46968    46980   +12     
  Branches                              6409     6410    +1     
================================================================
+ Hits                                 43951    43963   +12     
- Misses                                1949     1950    +1     
+ Partials                              1068     1067    -1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@jonathangreen
jonathangreen force-pushed the chore/overdrive-async-move branch from 6911395 to f9bbab8 Compare August 31, 2026 17:10
The async page fetching bypassed the token and endpoint machinery it sits
next to, so it belongs in the transport layer rather than on OverdriveAPI.
It gets its own class instead of joining OverdriveClientRequests, because
it shares almost none of that class's machinery: it runs on httpx rather
than requests, with its own retry and backoff policy, and holds one client
open across a whole page of products. What it does share is the bearer
token, which it reaches through the client request object it is handed, so
a collection still only ever holds one.

BookInfoEndpoint moves along with it, and its importers are updated in the
same change.

The unused extractor_class parameter is dropped rather than carried along:
no caller ever passed it, and the only thing it was used for was finding
the next-page link, which is now a small private helper. That keeps the
representation extractor, and the data layer behind it, out of the
transport layer.
@jonathangreen jonathangreen changed the title Move async book-info fetching to OverdriveClientRequests (PP-4938) Move async book-info fetching into its own request class (PP-4938) Sep 1, 2026
@jonathangreen
jonathangreen force-pushed the chore/overdrive-async-move branch from f9bbab8 to b5e0571 Compare September 1, 2026 17:47
The book info test queued the availability document ahead of the
bibliographic one, but fetch_book_info_list asks for metadata first, so
each document was attached to the other's key. Both assertions only
checked truthiness, so the test passed while proving nothing. Queue them
in the order they are requested and assert on a field that tells the two
documents apart.

Define the host template once at module level. It cannot go on the base
class: the templates that build on it are evaluated in a subclass's class
body, which does not resolve names from base classes.

The async fixture no longer takes the sync http client, which nothing on
it used.
Resolving the host from its own settings and taking the bearer token from
the client request object are the two things this class newly does for
itself, and neither was checked. The mock async client answers from a
queue whatever the URL and headers are, so a wrong host or a missing
Authorization header passed both here and in the celery test.

Build all three request fixtures from one settings factory rather than
three copies of the same five keyword arguments.
Nothing in the test tree stops a real network call, and async_http_client
only patches when a test asks for it, so a test using this fixture without
also naming that one would have gone to Overdrive. Take it as a fixture
argument, the way the sync request fixture already takes its client.
The fixture holds the mock client so that using it always mocks the
network, but the tests were still taking that client as a second parameter
and queueing against it directly, leaving the attribute unread. Queue
through the fixture, as the sync request tests do, and the extra parameter
goes away.
client_requests and patron_requests can both be handed in, so the async
one should be too rather than always being built in place.

Mock the sync client in the async fixture as well. Its OverdriveClientRequests
is real, and only the seeded token keeps it off the network.
A page with links but no next one is what ends the import loop, and it was
the one branch of _page_link nothing reached: the empty collection test
exits on the missing links key instead. The old code delegated this to the
representation extractor, which has its own test, so the gap arrived with
the move.

Stop storing the sync mock on the async fixture. The fixture still depends
on it, which is what keeps the real client it builds off the network, but
nothing reads the attribute.
Only OverdriveClientRequests needs it at class-body scope, where an
inherited attribute would not resolve. The async class reads it at
runtime and would be fine either way.

[skip claude]
@jonathangreen
jonathangreen requested a review from a team September 1, 2026 19:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant