diff --git a/bindery/cwa.py b/bindery/cwa.py index e488fb7..6ff08f7 100644 --- a/bindery/cwa.py +++ b/bindery/cwa.py @@ -80,8 +80,9 @@ class CwaClient: raise CwaError("CWA address is not set. Set CWA_URL.") # hrefs from the feed are absolute paths, and include any path prefix of CWA. url = urljoin(self.base_url, href) + kwargs.setdefault("timeout", self.timeout) try: - response = self.session.get(url, auth=auth or self.auth, timeout=self.timeout, **kwargs) + response = self.session.get(url, auth=auth or self.auth, **kwargs) except requests.RequestException as e: raise CwaError(f"Cannot reach CWA at {self.base_url}: {e}") from e if response.status_code == 401: @@ -153,8 +154,8 @@ class CwaClient: return [] return self._feed("opds/search", params={"query": query.strip()})[0] - def get_book(self, book_uuid: str) -> dict[str, Any] | None: - """A book by its calibre uuid, or None if CWA has no such book.""" + def get_book(self, book_uuid: str, timeout: int | None = None) -> dict[str, Any] | None: + """A book by its calibre uuid, or None if CWA has no such book. `timeout` is for a page that waits.""" # The uuid can come from a request. Other characters could change the path. if not re.fullmatch(r"[0-9A-Za-z-]+", book_uuid): return None @@ -165,7 +166,7 @@ class CwaClient: return book except calibre_db.DatabaseUnavailable as e: logger.warning(f"Cannot read the calibre library database. Trying CWA: {e}") - response = self._get(f"ajax/book/{book_uuid}") + response = self._get(f"ajax/book/{book_uuid}", timeout=timeout or self.timeout) if not response.text.strip(): return None try: diff --git a/bindery/orchestrator.py b/bindery/orchestrator.py index 04a36a0..0076803 100644 --- a/bindery/orchestrator.py +++ b/bindery/orchestrator.py @@ -202,6 +202,9 @@ def list_stories() -> list[dict[str, Any]]: return stories +# The seconds that the story page waits for CWA. A job waits for the full timeout of the client. +PAGE_TIMEOUT = 5 + # Only raster images. An SVG cover could hold a script that runs on Bindery's origin. COVER_TYPES = frozenset({"image/jpeg", "image/png", "image/gif", "image/webp"}) @@ -222,7 +225,7 @@ def story_details(story_id: int) -> dict[str, Any]: book, library_error = None, None if story["calibre_uuid"] and _can_match_books(): try: - book = cwa().get_book(story["calibre_uuid"]) + book = cwa().get_book(story["calibre_uuid"], timeout=PAGE_TIMEOUT) except CwaError as e: library_error = str(e) story["recheck_days"] = store.get_setting(story_id, "recheck_days") diff --git a/tests/test_api_flows.py b/tests/test_api_flows.py index 1346442..524cfcf 100644 --- a/tests/test_api_flows.py +++ b/tests/test_api_flows.py @@ -99,6 +99,7 @@ def test_library_search_link_import_and_unlink(client, story, monkeypatch): assert client.get("/api/library/search?q=Title").json[0]["tracked_as"] == "Story" cwa.get_book.side_effect = CwaError("offline") assert client.get(f"/api/stories/{sid}").json["library_error"] == "offline" + cwa.get_book.assert_called_with("book-7", timeout=5) # the page does not wait long for CWA cwa.get_book.side_effect = None cwa.get_book.return_value = None assert client.post(f"/api/stories/{sid}/link", json={"uuid": "missing"}, headers=H).status_code == 404 diff --git a/tests/test_cwa.py b/tests/test_cwa.py index 3794fa2..edd2493 100644 --- a/tests/test_cwa.py +++ b/tests/test_cwa.py @@ -75,6 +75,17 @@ def test_get_book(fake): assert c.get_book("nope") is None +def test_get_book_can_wait_for_a_shorter_time(fake, monkeypatch): + c = client(fake) + get = c.session.get + timeouts = [] + monkeypatch.setattr(c.session, "get", lambda url, **kw: timeouts.append(kw["timeout"]) or get(url, **kw)) + c.get_book("uuid-2", timeout=5) + c.get_book("uuid-2") + c.search("Story") + assert timeouts == [5, 60, 60] + + @pytest.mark.parametrize("book_uuid", ["../opds", "uuid-2?x=1", "uuid-2#x", "uuid%2F2", ""]) def test_get_book_refuses_a_uuid_that_changes_the_path(book_uuid): # Nothing listens on this port, so a request would raise CwaError.