Repository: ThePalaceProject/circulation
Pull request #2366: Type hints for HTTP request kwargs
Author: login: jonathangreen
Created: 2025-03-22T01:57:04Z | Merged: 2025-03-25T19:22:29Z

## Description

  This PR adds proper type hints to HTTP-related keyword arguments across the application. It introduces typed dictionaries for HTTP request keyword arguments, ensuring type safety when making HTTP requests.

  The changes include:

  - Added `GetRequestKwargs` and `RequestKwargs` typed dictionaries in http.py
  - Updated method signatures to use `Unpack[RequestKwargs]` for proper typing of kwargs
  - Created protocol classes for request callables to improve type checking
  - Updated tests to maintain compatibility with the new typing

## Motivation and Context

  This change improves type safety by properly typing HTTP request keyword arguments. Previously, these were untyped, which could lead to subtle bugs where incorrect parameter types were passed to HTTP requests. With these changes:

  - Static type checkers can catch errors at development time
  - IDE autocompletion provides better suggestions for available parameters
  - Code becomes more self-documenting through explicit parameter types

I am always forgetting what the param names for the http methods are, especially the palace specific ones, so the IDE autocomplete is really helpful here.

## How Has This Been Tested?

  The changes have been tested through:

  - Running the existing test suite to ensure all tests still pass
  - Manual verification of type checking using mypy
  - Ensuring IDE autocompletion correctly identifies parameter types

## Checklist

<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
<!--- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->

- [X] I have updated the documentation accordingly.
- [X] All new and existing tests passed.



Diff:
diff --git a/src/palace/manager/api/circulation.py b/src/palace/manager/api/circulation.py
index 281e135cdf..4f6a187206 100644
--- a/src/palace/manager/api/circulation.py
+++ b/src/palace/manager/api/circulation.py
@@ -63,7 +63,7 @@
 from palace.manager.sqlalchemy.model.resource import Resource
 from palace.manager.sqlalchemy.util import get_one
 from palace.manager.util.datetime_helpers import utc_now
-from palace.manager.util.http import HTTP, BadResponseException
+from palace.manager.util.http import HTTP, BadResponseException, ResponseCodesT
 from palace.manager.util.log import LoggerMixin
 
 
@@ -277,7 +277,7 @@ def __init__(
         content_type: str | None = None,
         *,
         include_headers: dict[str, str] | None = None,
-        allowed_response_codes: list[str | int] | None = None,
+        allowed_response_codes: ResponseCodesT = None,
     ) -> None:
         super().__init__(content_link, content_type)
         self.include_headers = include_headers or {}
diff --git a/src/palace/manager/api/discovery/opds_registration.py b/src/palace/manager/api/discovery/opds_registration.py
index d5ff0032fe..626ffcecd8 100644
--- a/src/palace/manager/api/discovery/opds_registration.py
+++ b/src/palace/manager/api/discovery/opds_registration.py
@@ -12,6 +12,7 @@
 from requests import Response
 from sqlalchemy import select
 from sqlalchemy.orm.session import Session
+from typing_extensions import Unpack
 
 from palace.manager.api.config import Configuration
 from palace.manager.api.problem_details import (
@@ -34,7 +35,7 @@
 from palace.manager.sqlalchemy.model.integration import IntegrationConfiguration
 from palace.manager.sqlalchemy.model.library import Library
 from palace.manager.sqlalchemy.util import get_one, get_one_or_create
-from palace.manager.util.http import HTTP
+from palace.manager.util.http import HTTP, RequestKwargs
 from palace.manager.util.problem_detail import ProblemDetailException
 from palace.manager.util.pydantic import HttpUrl
 
@@ -136,9 +137,10 @@ def get_request(url: str) -> Response:
 
     @staticmethod
     def post_request(
-        url: str, payload: str | dict[str, Any], **kwargs: Any
+        url: str,
+        **kwargs: Unpack[RequestKwargs],
     ) -> Response:
-        return HTTP.debuggable_post(url, payload, **kwargs)
+        return HTTP.debuggable_post(url, **kwargs)
 
     @classmethod
     def for_protocol_goal_and_url(
@@ -434,7 +436,7 @@ def _send_registration_request(
         response = cls.post_request(
             register_url,
             headers=headers,
-            payload=payload,
+            data=payload,
             timeout=60,
             allowed_response_codes=["2xx", "3xx"],
         )
diff --git a/src/palace/manager/api/kansas_patron.py b/src/palace/manager/api/kansas_patron.py
index 153c8048d1..1130973e53 100644
--- a/src/palace/manager/api/kansas_patron.py
+++ b/src/palace/manager/api/kansas_patron.py
@@ -135,7 +135,7 @@ def post_request(self, data):
         """
         return HTTP.post_with_timeout(
             self.base_url,
-            data,
+            data=data,
             headers={"Content-Type": "application/xml"},
             max_retry_count=0,
             allowed_response_codes=["2xx"],
diff --git a/src/palace/manager/api/metadata/novelist.py b/src/palace/manager/api/metadata/novelist.py
index a6ccff6d5f..e328d2b731 100644
--- a/src/palace/manager/api/metadata/novelist.py
+++ b/src/palace/manager/api/metadata/novelist.py
@@ -752,9 +752,8 @@ def make_novelist_data_object(self, items: list[dict[str, str]]) -> dict[str, An
         }
 
     def put(self, url: str, headers: Mapping[str, str], **kwargs: Any) -> Response:
-        data = kwargs.pop("data", None)
         # This might take a very long time -- disable the normal
         # timeout.
         kwargs["timeout"] = None
-        response = HTTP.put_with_timeout(url, data, headers=headers, **kwargs)
+        response = HTTP.put_with_timeout(url, headers=headers, **kwargs)
         return response
diff --git a/src/palace/manager/api/odl/auth.py b/src/palace/manager/api/odl/auth.py
index 7c08426f50..8ad29c17b2 100644
--- a/src/palace/manager/api/odl/auth.py
+++ b/src/palace/manager/api/odl/auth.py
@@ -1,5 +1,5 @@
 from abc import ABC, abstractmethod
-from collections.abc import Mapping, Sequence
+from collections.abc import Mapping
 from datetime import datetime, timedelta
 from typing import Any, Literal, NamedTuple
 
@@ -11,7 +11,12 @@
 from palace.manager.core.exceptions import IntegrationException, PalaceValueError
 from palace.manager.opds.authentication import AuthenticationDocument
 from palace.manager.util.datetime_helpers import utc_now
-from palace.manager.util.http import HTTP, BadResponseException, BearerAuth
+from palace.manager.util.http import (
+    HTTP,
+    BadResponseException,
+    BearerAuth,
+    ResponseCodesT,
+)
 from palace.manager.util.log import LoggerMixin
 from palace.manager.util.problem_detail import ProblemDetail
 
@@ -140,8 +145,8 @@ def _oauth_request(
     ) -> Response:
         # If the request restricts allowed response codes, we need to add 401 to the allowed response codes
         # so that we can handle the 401 response and refresh the token, if necessary.
-        original_allowed_response_codes: Sequence[int | str] = kwargs.get(
-            "allowed_response_codes", []
+        original_allowed_response_codes: ResponseCodesT = (
+            kwargs.get("allowed_response_codes") or []
         )
         if (
             original_allowed_response_codes
diff --git a/src/palace/manager/api/odl/importer.py b/src/palace/manager/api/odl/importer.py
index 39ff85d8e7..add27d9238 100644
--- a/src/palace/manager/api/odl/importer.py
+++ b/src/palace/manager/api/odl/importer.py
@@ -30,7 +30,7 @@
     RightsStatus,
 )
 from palace.manager.sqlalchemy.model.resource import Hyperlink
-from palace.manager.util.http import HTTP
+from palace.manager.util.http import HTTP, GetRequestCallable
 
 
 class OPDS2WithODLImporter(OPDS2Importer):
@@ -59,7 +59,7 @@ def __init__(
         db: Session,
         collection: Collection,
         data_source_name: str | None = None,
-        http_get: Callable[..., Response] | None = None,
+        http_get: GetRequestCallable | None = None,
     ):
         """Initialize a new instance of OPDS2WithODLImporter class.
 
diff --git a/src/palace/manager/api/overdrive/api.py b/src/palace/manager/api/overdrive/api.py
index 38649852bc..78d2603759 100644
--- a/src/palace/manager/api/overdrive/api.py
+++ b/src/palace/manager/api/overdrive/api.py
@@ -642,7 +642,7 @@ def _do_post(
         url = self.endpoint(url)
         kwargs["max_retry_count"] = self.settings.max_retry_count
         kwargs["timeout"] = 120
-        return HTTP.post_with_timeout(url, payload, headers=headers, **kwargs)
+        return HTTP.post_with_timeout(url, data=payload, headers=headers, **kwargs)
 
     def website_id(self) -> str:
         return self.settings.overdrive_website_id
diff --git a/src/palace/manager/core/opds_import.py b/src/palace/manager/core/opds_import.py
index abf9b3e4f0..ec7140299f 100644
--- a/src/palace/manager/core/opds_import.py
+++ b/src/palace/manager/core/opds_import.py
@@ -1703,14 +1703,15 @@ def _get(self, url: str, headers: Mapping[str, str]) -> Response:
         """
 
         headers = self._update_headers(headers)
-        kwargs = dict(
+        if not url.startswith("http"):
+            url = urljoin(self._feed_base_url, url)
+        return HTTP.get_with_timeout(
+            url,
+            headers=headers,
             timeout=120,
             max_retry_count=self._max_retry_count,
             allowed_response_codes=["2xx", "3xx"],
         )
-        if not url.startswith("http"):
-            url = urljoin(self._feed_base_url, url)
-        return HTTP.get_with_timeout(url, headers=headers, **kwargs)
 
     def _get_accept_header(self) -> str:
         return ",".join(
@@ -1862,7 +1863,7 @@ def follow_one_link(
         """
         self.log.info("Following next link: %s", url)
         get = do_get or self._get
-        resp = get(url, {})
+        resp = get(url, headers={})
         feed = resp.content
 
         self._verify_media_type(url, resp)
diff --git a/src/palace/manager/sqlalchemy/model/resource.py b/src/palace/manager/sqlalchemy/model/resource.py
index 462522fbac..ff0c4e522b 100644
--- a/src/palace/manager/sqlalchemy/model/resource.py
+++ b/src/palace/manager/sqlalchemy/model/resource.py
@@ -1050,10 +1050,7 @@ def simple_http_get(cls, url, headers, **kwargs) -> HttpResponseTuple:
     @classmethod
     def simple_http_post(cls, url, headers, **kwargs):
         """The most simple HTTP-based POST."""
-        data = kwargs.get("data")
-        if "data" in kwargs:
-            del kwargs["data"]
-        response = HTTP.post_with_timeout(url, data, headers=headers, **kwargs)
+        response = HTTP.post_with_timeout(url, headers=headers, **kwargs)
         return response.status_code, response.headers, response.content
 
     @classmethod
diff --git a/src/palace/manager/util/http.py b/src/palace/manager/util/http.py
index fea3938f18..83a53b4c89 100644
--- a/src/palace/manager/util/http.py
+++ b/src/palace/manager/util/http.py
@@ -2,9 +2,10 @@
 
 import logging
 import time
-from collections.abc import Callable, Mapping, Sequence
+from collections.abc import Callable, Iterable, Mapping
+from io import BytesIO, StringIO
 from json import JSONDecodeError
-from typing import Any
+from typing import Any, Literal, Protocol, TypedDict
 from urllib.parse import urlparse
 
 import requests
@@ -12,7 +13,7 @@
 from requests import PreparedRequest, sessions
 from requests.adapters import HTTPAdapter, Response
 from requests.auth import AuthBase
-from typing_extensions import Self
+from typing_extensions import Self, Unpack
 from urllib3 import Retry
 
 from palace import manager
@@ -145,6 +146,59 @@ class RequestTimedOut(RequestNetworkException, requests.exceptions.Timeout):
     internal_message = "Timeout accessing %s: %s"
 
 
+_ResponseCodesLiteral = Literal["2xx", "3xx", "4xx", "5xx"]
+ResponseCodesT = Iterable[_ResponseCodesLiteral | int] | None
+
+
+class GetRequestKwargs(TypedDict, total=False):
+    params: Mapping[str, str | int | float | None] | None
+    headers: Mapping[str, str | None] | None
+    auth: tuple[str, str] | AuthBase | None
+    timeout: float | int | None
+    allow_redirects: bool
+    stream: bool | None
+    verify: bool | None
+
+    allowed_response_codes: ResponseCodesT
+    disallowed_response_codes: ResponseCodesT
+    verbose: bool
+    max_retry_count: int
+    backoff_factor: float
+
+
+class RequestKwargs(GetRequestKwargs, total=False):
+    data: Iterable[bytes] | str | bytes | Mapping[str, Any] | None
+    files: Mapping[str, BytesIO | StringIO | str | bytes] | None
+    json: Mapping[str, Any] | None
+
+
+class GetRequestCallable(Protocol):
+    def __call__(
+        self,
+        url: str,
+        **kwargs: Unpack[GetRequestKwargs],
+    ) -> Response: ...
+
+
+class MakeRequestCallable(Protocol):
+    def __call__(
+        self,
+        method: str,
+        url: str,
+        **kwargs: Unpack[RequestKwargs],
+    ) -> Response: ...
+
+
+class _ProcessResponseCallable(Protocol):
+    def __call__(
+        self,
+        url: str,
+        response: Response,
+        allowed_response_codes: ResponseCodesT = None,
+        disallowed_response_codes: ResponseCodesT = None,
+    ) -> Response: ...
+
+
 class HTTP(LoggerMixin):
     """A helper for the `requests` module."""
 
@@ -162,35 +216,37 @@ def set_quick_failure_settings(cls) -> None:
         cls.DEFAULT_REQUEST_TIMEOUT = 5
 
     @classmethod
-    def get_with_timeout(cls, url: str, *args: Any, **kwargs: Any) -> Response:
+    def get_with_timeout(cls, url: str, **kwargs: Unpack[GetRequestKwargs]) -> Response:
         """Make a GET request with timeout handling."""
-        return cls.request_with_timeout("GET", url, *args, **kwargs)
+        return cls.request_with_timeout("GET", url, **kwargs)
 
     @classmethod
     def post_with_timeout(
-        cls, url: str, payload: str | Mapping[str, Any], *args: Any, **kwargs: Any
+        cls,
+        url: str,
+        **kwargs: Unpack[RequestKwargs],
     ) -> Response:
         """Make a POST request with timeout handling."""
-        kwargs["data"] = payload
-        return cls.request_with_timeout("POST", url, *args, **kwargs)
+        return cls.request_with_timeout("POST", url, **kwargs)
 
     @classmethod
     def put_with_timeout(
-        cls, url: str, payload: str | Mapping[str, Any], *args: Any, **kwargs: Any
+        cls,
+        url: str,
+        **kwargs: Unpack[RequestKwargs],
     ) -> Response:
         """Make a PUT request with timeout handling."""
-        kwargs["data"] = payload
-        return cls.request_with_timeout("PUT", url, *args, **kwargs)
+        return cls.request_with_timeout("PUT", url, **kwargs)
 
     @classmethod
     def request_with_timeout(
-        cls, http_method: str, url: str, *args: Any, **kwargs: Any
+        cls, http_method: str, url: str, **kwargs: Unpack[RequestKwargs]
     ) -> Response:
         """Call requests.request and turn a timeout into a RequestTimedOut
         exception.
         """
         return cls._request_with_timeout(
-            url, sessions.Session.request, http_method, *args, **kwargs
+            http_method, url, sessions.Session.request, **kwargs
         )
 
     # The set of status codes on which a retry will be attempted (if the number of retries requested is non-zero).
@@ -199,10 +255,11 @@ def request_with_timeout(
     @classmethod
     def _request_with_timeout(
         cls,
+        http_method: str,
         url: str,
         make_request_with: Callable[..., Response],
-        *args: Any,
-        **kwargs: Any,
+        process_response_with: _ProcessResponseCallable | None = None,
+        **kwargs: Unpack[RequestKwargs],
     ) -> Response:
         """Call some kind of method and turn a timeout into a RequestTimedOut
         exception.
@@ -215,9 +272,8 @@ def _request_with_timeout(
         :param args: Positional arguments for the request function.
         :param kwargs: Keyword arguments for the request function.
         """
-        process_response_with = kwargs.pop(
-            "process_response_with", cls._process_response
-        )
+        process_response_with = process_response_with or cls._process_response
+
         allowed_response_codes = kwargs.pop("allowed_response_codes", [])
         disallowed_response_codes = kwargs.pop("disallowed_response_codes", [])
         verbose = kwargs.pop("verbose", False)
@@ -230,39 +286,24 @@ def _request_with_timeout(
         )
         backoff_factor: float = float(kwargs.pop("backoff_factor", 1.0))
 
-        # Unicode data can't be sent over the wire. Convert it to UTF-8.
-        if "data" in kwargs and isinstance(kwargs["data"], str):
-            kwdata: str = kwargs["data"]
-            kwargs["data"] = kwdata.encode("utf8")
-
         # Set a user-agent if not already present
         version = (
             manager.__version__
             if manager.__version__
             else cls.DEFAULT_USER_AGENT_VERSION
         )
-        ua_header = {"User-Agent": f"Palace Manager/{version}"}
-        headers = ua_header | (kwargs.get("headers") or {})
-
-        # Make sure headers are encoded as utf-8
-        kwargs["headers"] = {
-            k.encode() if isinstance(k, str) else k: (
-                v.encode() if isinstance(v, str) else v
-            )
-            for k, v in headers.items()
-        }
+        headers: dict[str, str | None] = {"User-Agent": f"Palace Manager/{version}"}
+        if (additional_headers := kwargs.get("headers")) is not None:
+            headers.update(additional_headers)
+        kwargs["headers"] = headers
 
         try:
             if verbose:
                 logging.info(
-                    "Sending request to %s: args %r kwargs %r", url, args, kwargs
+                    f"Sending {http_method} request to {url}: kwargs {kwargs!r}",
+                    url,
+                    kwargs,
                 )
-            if len(args) == 1:
-                # requests.request takes two positional arguments,
-                # an HTTP method and a URL. In most cases, the URL
-                # gets added on here. But if you do pass in both
-                # arguments, it will still work.
-                args = args + (url,)
 
             request_start_time = time.time()
             if make_request_with == sessions.Session.request:
@@ -277,9 +318,9 @@ def _request_with_timeout(
                     session.mount("http://", adapter)
                     session.mount("https://", adapter)
 
-                    response = session.request(*args, **kwargs)
+                    response = session.request(http_method, url, **kwargs)  # type: ignore[misc]
             else:
-                response = make_request_with(*args, **kwargs)
+                response = make_request_with(http_method, url, **kwargs)
             cls.logger().info(
                 f"Request time for {url} took {time.time() - request_start_time:.2f} seconds"
             )
@@ -301,7 +342,7 @@ def _request_with_timeout(
             # a generic RequestNetworkException.
             raise RequestNetworkException(url, str(e)) from e
 
-        return process_response_with(  # type: ignore[no-any-return]
+        return process_response_with(
             url,
             response,
             allowed_response_codes,
@@ -313,8 +354,8 @@ def _process_response(
         cls,
         url: str,
         response: Response,
-        allowed_response_codes: Sequence[int | str] | None = None,
-        disallowed_response_codes: Sequence[int | str] | None = None,
+        allowed_response_codes: ResponseCodesT = None,
+        disallowed_response_codes: ResponseCodesT = None,
     ) -> Response:
         """Raise a RequestNetworkException if the response code indicates a
         server-side failure, or behavior so unpredictable that we can't
@@ -380,12 +421,12 @@ def _decode_response_content(cls, response: Response, url: str) -> str:
             raise RequestNetworkException(url, str(e)) from e
 
     @classmethod
-    def series(cls, status_code: int) -> str:
+    def series(cls, status_code: int) -> _ResponseCodesLiteral:
         """Return the HTTP series for the given status code."""
-        return "%sxx" % (int(status_code) // 100)
+        return "%sxx" % (int(status_code) // 100)  # type: ignore[return-value]
 
     @classmethod
-    def debuggable_get(cls, url: str, **kwargs: Any) -> Response:
+    def debuggable_get(cls, url: str, **kwargs: Unpack[GetRequestKwargs]) -> Response:
         """Make a GET request that returns a detailed problem
         detail document on error.
         """
@@ -393,12 +434,13 @@ def debuggable_get(cls, url: str, **kwargs: Any) -> Response:
 
     @classmethod
     def debuggable_post(
-        cls, url: str, payload: str | dict[str, Any], **kwargs: Any
+        cls,
+        url: str,
+        **kwargs: Unpack[RequestKwargs],
     ) -> Response:
         """Make a POST request that returns a detailed problem
         detail document on error.
         """
-        kwargs["data"] = payload
         return cls.debuggable_request("POST", url, **kwargs)
 
     @classmethod
@@ -407,7 +449,7 @@ def debuggable_request(
         http_method: str,
         url: str,
         make_request_with: Callable[..., Response] | None = None,
-        **kwargs: Any,
+        **kwargs: Unpack[RequestKwargs],
     ) -> Response:
         """Make a request that raises a ProblemError with a detailed problem detail
         document on error, rather than a generic "an integration error occurred"
@@ -425,9 +467,9 @@ def debuggable_request(
         )
         make_request_with = make_request_with or requests.request
         return cls._request_with_timeout(
-            url,
-            make_request_with,
             http_method,
+            url,
+            make_request_with=make_request_with,
             process_response_with=cls.process_debuggable_response,
             **kwargs,
         )
@@ -437,14 +479,12 @@ def process_debuggable_response(
         cls,
         url: str,
         response: Response,
-        allowed_response_codes: list[str | int] | None = None,
-        disallowed_response_codes: list[str | int] | None = None,
+        allowed_response_codes: ResponseCodesT = None,
+        disallowed_response_codes: ResponseCodesT = None,
     ) -> Response:
         """If there was a problem with an integration request,
         raise ProblemError with an appropriate ProblemDetail. Otherwise, return the
         response to the original request.
-
-        :param response: A Response object from the requests library.
         """
 
         allowed_response_codes = allowed_response_codes or ["2xx", "3xx"]
diff --git a/tests/manager/api/metadata/test_novelist.py b/tests/manager/api/metadata/test_novelist.py
index 0ea269edaf..a8962231b6 100644
--- a/tests/manager/api/metadata/test_novelist.py
+++ b/tests/manager/api/metadata/test_novelist.py
@@ -745,5 +745,5 @@ def test_put(self, novelist_fixture: NoveListFixture, monkeypatch: MonkeyPatch):
 
         novelist_fixture.novelist.put("http://apiendpoint.com", headers, data=data)
         mock_put.assert_called_once_with(
-            "http://apiendpoint.com", data, headers=headers, timeout=None
+            "http://apiendpoint.com", data=data, headers=headers, timeout=None
         )
diff --git a/tests/manager/api/test_circulation.py b/tests/manager/api/test_circulation.py
index d630a144f1..a2ef6d104c 100644
--- a/tests/manager/api/test_circulation.py
+++ b/tests/manager/api/test_circulation.py
@@ -84,7 +84,7 @@ def test_fetch_fulfillment(self) -> None:
         assert response.get_data(as_text=True) == "Other content."
         assert response.content_type == "application/xyz"
         assert http.requests == ["http://some.other.location"]
-        [(args, kwargs)] = http.requests_args
+        [kwargs] = http.requests_args
         assert kwargs["allow_redirects"] is True
 
         # If the content type is not set on the fulfillment, and the response does not have a content type,
@@ -115,7 +115,8 @@ def test_fetch_fulfillment_include_headers(self) -> None:
         assert response.content_type == "foo/bar"
         assert "X-Test" not in response.headers
         assert http.requests == ["http://some.location"]
-        [(args, kwargs)] = http.requests_args
+        [kwargs] = http.requests_args
+        assert kwargs["headers"] is not None
         assert kwargs["headers"]["X-Test"] == "test"
 
     def test_fetch_fulfillment_allowed_response_codes(
diff --git a/tests/manager/util/test_http.py b/tests/manager/util/test_http.py
index e5309268fb..6d33748f0e 100644
--- a/tests/manager/util/test_http.py
+++ b/tests/manager/util/test_http.py
@@ -1,7 +1,12 @@
+from collections.abc import Mapping, Sequence
+from functools import partial
+from typing import Any
 from unittest import mock
+from unittest.mock import MagicMock
 
 import pytest
 import requests
+from requests import Response
 from requests_mock import Mocker
 
 from palace.manager.core.problem_details import INTEGRATION_ERROR, INVALID_INPUT
@@ -17,34 +22,32 @@
 from tests.mocks.mock import MockRequestsResponse
 
 
+class FakeRequest:
+    def __init__(self, response: Response | None = None) -> None:
+        self.agent: str | None = None
+        self.args: Sequence[Any] | None = None
+        self.kwargs: Mapping[str, Any] | None = None
+        self.response = response or MockRequestsResponse(201)
+
+    def fake_request(self, *args: Any, **kwargs: Any) -> Response:
+        self.agent = kwargs["headers"]["User-Agent"]
+        self.args = args
+        self.kwargs = kwargs
+        return self.response
+
+
 class TestHTTP:
-    @pytest.fixture
-    def mock_request(self):
-        class FakeRequest:
-            def __init__(self, response=None):
-                self.agent = None
-                self.args = None
-                self.kwargs = None
-                self.response = response or MockRequestsResponse(201)
-
-            def fake_request(self, *args, **kwargs):
-                self.agent = kwargs["headers"][b"User-Agent"]
-                self.args = args
-                self.kwargs = kwargs
-                return self.response
-
-        return FakeRequest
-
-    def test_series(self):
+    def test_series(self) -> None:
         m = HTTP.series
-        assert "2xx" == m(201)
-        assert "3xx" == m(399)
-        assert "5xx" == m(500)
+        assert m(201) == "2xx"
+        assert m(399) == "3xx"
+        assert m(500) == "5xx"
 
     @mock.patch("palace.manager.util.http.sessions.Session")
-    def test_request_with_timeout_defaults(self, mock_session):
-        with mock.patch.object(HTTP, "DEFAULT_REQUEST_TIMEOUT", 10), mock.patch.object(
-            HTTP, "DEFAULT_REQUEST_RETRIES", 2
+    def test_request_with_timeout_defaults(self, mock_session: MagicMock) -> None:
+        with (
+            mock.patch.object(HTTP, "DEFAULT_REQUEST_TIMEOUT", 10),
+            mock.patch.object(HTTP, "DEFAULT_REQUEST_RETRIES", 2),
         ):
             mock_ctx = mock_session().__enter__()
             mock_request = mock_ctx.request
@@ -56,16 +59,16 @@ def test_request_with_timeout_defaults(self, mock_session):
             assert mock_request.call_args[1]["timeout"] == 10
 
     @mock.patch("palace.manager.util.http.manager.__version__", "<VERSION>")
-    def test_request_with_timeout_success(self, mock_request):
-        request = mock_request(MockRequestsResponse(200, content="Success!"))
+    def test_request_with_timeout_success(self) -> None:
+        request = FakeRequest(MockRequestsResponse(200, content="Success!"))
         response = HTTP._request_with_timeout(
-            "http://url/", request.fake_request, "GET", kwarg="value"
+            "GET", "http://url/", request.fake_request, kwarg="value"  # type: ignore[call-arg]
         )
-        assert 200 == response.status_code
-        assert b"Success!" == response.content
+        assert response.status_code == 200
+        assert response.content == b"Success!"
 
         # User agent header should be set
-        assert b"Palace Manager/<VERSION>" == request.agent
+        assert request.agent == "Palace Manager/<VERSION>"
 
         # The HTTP method and URL are passed in the order
         # requests.request would expect.
@@ -73,195 +76,161 @@ def test_request_with_timeout_success(self, mock_request):
 
         # Keyword arguments to _request_with_timeout are passed in
         # as-is.
-        assert "value" == request.kwargs["kwarg"]
+        assert request.kwargs is not None
+        assert request.kwargs["kwarg"] == "value"
 
         # A default timeout is added.
-        assert 20 == request.kwargs["timeout"]
+        assert request.kwargs["timeout"] == 20
 
-    def test_request_with_timeout_with_ua(self, mock_request):
-        request = mock_request()
+    def test_request_with_timeout_with_ua(self) -> None:
+        request = FakeRequest()
         assert (
             HTTP._request_with_timeout(
+                "GET",
                 "http://url",
                 request.fake_request,
-                "GET",
                 headers={"User-Agent": "Fake Agent"},
             ).status_code
             == 201
         )
-        assert request.agent == b"Fake Agent"
+        assert request.agent == "Fake Agent"
 
     @mock.patch("palace.manager.util.http.manager.__version__", None)
-    def test_default_user_agent(self, mock_request):
-        request = mock_request()
-        assert HTTP._request_with_timeout("/", request.fake_request).status_code == 201
-        assert request.agent == b"Palace Manager/1.x.x"
+    def test_default_user_agent(self) -> None:
+        request = FakeRequest()
+        assert (
+            HTTP._request_with_timeout("DELETE", "/", request.fake_request).status_code
+            == 201
+        )
+        assert request.agent == "Palace Manager/1.x.x"
 
         # User agent is still set if headers are None
         assert (
             HTTP._request_with_timeout(
-                "/", request.fake_request, headers=None
+                "GET", "/", request.fake_request, headers=None
             ).status_code
             == 201
         )
-        assert request.agent == b"Palace Manager/1.x.x"
+        assert request.agent == "Palace Manager/1.x.x"
 
         # The headers are not modified if they are passed into the function
         original_headers = {"header": "value"}
         assert (
             HTTP._request_with_timeout(
-                "/", request.fake_request, headers=original_headers
+                "GET", "/", request.fake_request, headers=original_headers
             ).status_code
             == 201
         )
-        assert request.agent == b"Palace Manager/1.x.x"
+        assert request.agent == "Palace Manager/1.x.x"
         assert original_headers == {"header": "value"}
 
-    def test_request_with_timeout_failure(self):
-        def immediately_timeout(*args, **kwargs):
+    def test_request_with_timeout_failure(self) -> None:
+        def immediately_timeout(*args, **kwargs) -> Response:
             raise requests.exceptions.Timeout("I give up")
 
-        with pytest.raises(RequestTimedOut) as excinfo:
-            HTTP._request_with_timeout("http://url/", immediately_timeout, "a", "b")
-        assert "Timeout accessing http://url/: I give up" in str(excinfo.value)
+        with pytest.raises(
+            RequestTimedOut, match="Timeout accessing http://url/: I give up"
+        ):
+            HTTP._request_with_timeout("PUT", "http://url/", immediately_timeout)
 
-    def test_request_with_network_failure(self):
-        def immediately_fail(*args, **kwargs):
+    def test_request_with_network_failure(self) -> None:
+        def immediately_fail(*args, **kwargs) -> Response:
             raise requests.exceptions.ConnectionError("a disaster")
 
-        with pytest.raises(RequestNetworkException) as excinfo:
-            HTTP._request_with_timeout("http://url/", immediately_fail, "a", "b")
-        assert "Network error contacting http://url/: a disaster" in str(excinfo.value)
+        with pytest.raises(
+            RequestNetworkException,
+            match="Network error contacting http://url/: a disaster",
+        ):
+            HTTP._request_with_timeout("POST", "http://url/", immediately_fail)
 
-    def test_request_with_response_indicative_of_failure(self):
-        def fake_500_response(*args, **kwargs):
+    def test_request_with_response_indicative_of_failure(self) -> None:
+        def fake_500_response(*args, **kwargs) -> Response:
             return MockRequestsResponse(500, content="Failure!")
 
-        with pytest.raises(BadResponseException) as excinfo:
-            HTTP._request_with_timeout("http://url/", fake_500_response, "a", "b")
-        assert (
-            "Bad response from http://url/: Got status code 500 from external server"
-            in str(excinfo.value)
-        )
+        with pytest.raises(
+            BadResponseException,
+            match="Bad response from http://url/: Got status code 500 from external server",
+        ):
+            HTTP._request_with_timeout("GET", "http://url/", fake_500_response)
 
-    def test_allowed_response_codes(self):
+    def test_allowed_response_codes(self) -> None:
         """Test our ability to raise BadResponseException when
         an HTTP-based integration does not behave as we'd expect.
         """
 
-        def fake_401_response(*args, **kwargs):
+        def fake_401_response(*args, **kwargs) -> Response:
             return MockRequestsResponse(401, content="Weird")
 
-        def fake_200_response(*args, **kwargs):
+        def fake_200_response(*args, **kwargs) -> Response:
             return MockRequestsResponse(200, content="Hurray")
 
         url = "http://url/"
-        m = HTTP._request_with_timeout
+        request = partial(HTTP._request_with_timeout, "GET", url)
 
         # By default, every code except for 5xx codes is allowed.
-        response = m(url, fake_401_response)
-        assert 401 == response.status_code
+        response = request(fake_401_response)
+        assert response.status_code == 401
 
         # You can say that certain codes are specifically allowed, and
         # all others are forbidden.
-        with pytest.raises(BadResponseException) as excinfo:
-            m(url, fake_401_response, allowed_response_codes=[201, 200])
-        assert (
-            "Bad response from http://url/: Got status code 401 from external server, but can only continue on: 200, 201."
-            in str(excinfo.value)
-        )
+        with pytest.raises(
+            BadResponseException,
+            match="Bad response from http://url/: Got status code 401 from external server, but can only continue on: 200, 201.",
+        ):
+            request(fake_401_response, allowed_response_codes=[201, 200])
 
-        response = m(url, fake_401_response, allowed_response_codes=[401])
-        response = m(url, fake_401_response, allowed_response_codes=["4xx"])
+        response = request(fake_401_response, allowed_response_codes=[401])
+        response = request(fake_401_response, allowed_response_codes=["4xx"])
 
         # In this way you can even raise an exception on a 200 response code.
-        with pytest.raises(BadResponseException) as excinfo:
-            m(url, fake_200_response, allowed_response_codes=[401])
-        assert (
-            "Bad response from http://url/: Got status code 200 from external server, but can only continue on: 401."
-            in str(excinfo.value)
-        )
+        with pytest.raises(
+            BadResponseException,
+            match="Bad response from http://url/: Got status code 200 from external server, but can only continue on: 401.",
+        ):
+            request(fake_200_response, allowed_response_codes=[401])
 
         # You can say that certain codes are explicitly forbidden, and
         # all others are allowed.
-        with pytest.raises(BadResponseException) as excinfo:
-            m(url, fake_401_response, disallowed_response_codes=[401])
-        assert (
-            "Bad response from http://url/: Got status code 401 from external server, cannot continue."
-            in str(excinfo.value)
-        )
-
-        with pytest.raises(BadResponseException) as excinfo:
-            m(url, fake_200_response, disallowed_response_codes=["2xx", 301])
-        assert (
-            "Bad response from http://url/: Got status code 200 from external server, cannot continue."
-            in str(excinfo.value)
-        )
+        with pytest.raises(
+            BadResponseException,
+            match="Bad response from http://url/: Got status code 401 from external server, cannot continue.",
+        ) as excinfo:
+            request(fake_401_response, disallowed_response_codes=[401])
+
+        with pytest.raises(
+            BadResponseException,
+            match="Bad response from http://url/: Got status code 200 from external server, cannot continue.",
+        ):
+            request(fake_200_response, disallowed_response_codes=["2xx", 301])
 
-        response = m(url, fake_401_response, disallowed_response_codes=["2xx"])
-        assert 401 == response.status_code
+        response = request(fake_401_response, disallowed_response_codes=["2xx"])
+        assert response.status_code == 401
 
         # The exception can be turned into a useful problem detail document.
-        exc = None
-        try:
-            m(url, fake_200_response, disallowed_response_codes=["2xx"])
-        except Exception as e:
-            exc = e
-        assert exc is not None
+        with pytest.raises(BadResponseException) as exc_info:
+            request(fake_200_response, disallowed_response_codes=["2xx"])
 
-        problem_detail = exc.problem_detail
+        problem_detail = exc_info.value.problem_detail
 
         # 502 is the status code to be returned if this integration error
         # interrupts the processing of an incoming HTTP request, not the
         # status code that caused the problem.
         #
-        assert 502 == problem_detail.status_code
-        assert "Bad response" == problem_detail.title
+        assert problem_detail.status_code == 502
+        assert problem_detail.title == "Bad response"
         assert (
-            "The server made a request to url, and got an unexpected or invalid response."
-            == problem_detail.detail
+            problem_detail.detail
+            == "The server made a request to url, and got an unexpected or invalid response."
         )
         assert (
-            "Bad response from http://url/: Got status code 200 from external server, cannot continue.\n\nResponse content: Hurray"
-            == problem_detail.debug_message
+            problem_detail.debug_message
+            == "Bad response from http://url/: Got status code 200 from external server, cannot continue.\n\nResponse content: Hurray"
         )
 
-    def test_unicode_converted_to_utf8(self):
-        """Any Unicode that sneaks into the URL, headers or body is
-        converted to UTF-8.
-        """
-
-        class ResponseGenerator:
-            def __init__(self):
-                self.requests = []
-
-            def response(self, *args, **kwargs):
-                self.requests.append((args, kwargs))
-                return MockRequestsResponse(200, content="Success!")
-
-        generator = ResponseGenerator()
-        url = "http://foo"
-        response = HTTP._request_with_timeout(
-            url,
-            generator.response,
-            "POST",
-            headers={"unicode header": "unicode value"},
-            data="unicode data",
-        )
-        [(args, kwargs)] = generator.requests
-        url, method = args
-        headers = kwargs["headers"]
-        data = kwargs["data"]
-
-        # All the Unicode data was converted to bytes before being sent
-        # "over the wire".
-        for k, v in list(headers.items()):
-            assert isinstance(k, bytes)
-            assert isinstance(v, bytes)
-        assert isinstance(data, bytes)
-
-    def test_debuggable_request(self):
+    def test_debuggable_request(self) -> None:
         class Mock(HTTP):
+            called_with: Sequence[Any] | None = None
+
             @classmethod
             def _request_with_timeout(cls, *args, **kwargs):
                 cls.called_with = (args, kwargs)
@@ -272,14 +241,16 @@ def mock_request(*args, **kwargs):
             return response
 
         Mock.debuggable_request(
-            "method", "url", make_request_with=mock_request, key="value"
+            "method", "url", make_request_with=mock_request, key="value"  # type: ignore[call-arg]
         )
+        assert Mock.called_with is not None
         (args, kwargs) = Mock.called_with
-        assert args == ("url", mock_request, "method")
+        assert args == ("method", "url")
         assert kwargs["key"] == "value"
+        assert kwargs["make_request_with"] == mock_request
         assert kwargs["process_response_with"] == Mock.process_debuggable_response
 
-    def test_process_debuggable_response(self):
+    def test_process_debuggable_response(self) -> None:
         """Test a method that gives more detailed information when a
         problem happens.
         """
@@ -314,7 +285,7 @@ def test_process_debuggable_response(self):
         # You can force a response to be treated as successful by
         # passing in its response code as allowed_response_codes.
         assert error == m("url", error, allowed_response_codes=[400])
-        assert error == m("url", error, allowed_response_codes=["400"])
+        assert error == m("url", error, allowed_response_codes=["400"])  # type: ignore[list-item]
         assert error == m("url", error, allowed_response_codes=["4xx"])
 
 
diff --git a/tests/mocks/mock.py b/tests/mocks/mock.py
index 73d1932fd8..d558ea418f 100644
--- a/tests/mocks/mock.py
+++ b/tests/mocks/mock.py
@@ -2,10 +2,11 @@
 import logging
 from collections.abc import Generator
 from contextlib import contextmanager
-from typing import Any, NamedTuple
+from typing import Any
 from unittest.mock import patch
 
 from requests import Request, Response
+from typing_extensions import Unpack
 
 from palace.manager.core.coverage import (
     BibliographicCoverageProvider,
@@ -16,7 +17,7 @@
 from palace.manager.core.opds_import import OPDSAPI
 from palace.manager.sqlalchemy.model.datasource import DataSource
 from palace.manager.sqlalchemy.model.resource import HttpResponseTuple
-from palace.manager.util.http import HTTP
+from palace.manager.util.http import HTTP, GetRequestKwargs, RequestKwargs
 
 
 def _normalize_level(level):
@@ -224,18 +225,11 @@ def process_batch(self, batch):
         return []
 
 
-class Args(NamedTuple):
-    """A simple container for positional and keyword arguments."""
-
-    args: tuple[Any, ...]
-    kwargs: dict[str, Any]
-
-
 class MockHTTPClient:
     def __init__(self) -> None:
         self.responses: list[Response] = []
         self.requests: list[str] = []
-        self.requests_args: list[Args] = []
+        self.requests_args: list[RequestKwargs] = []
         self.requests_methods: list[str] = []
 
     def queue_response(
@@ -255,14 +249,16 @@ def queue_response(
     def _request(self, *args: Any, **kwargs: Any) -> Response:
         return self.responses.pop(0)
 
-    def do_request(self, method: str, url: str, *args: Any, **kwargs: Any) -> Response:
+    def do_request(
+        self, http_method: str, url: str, **kwargs: Unpack[RequestKwargs]
+    ) -> Response:
         self.requests.append(url)
-        self.requests_methods.append(method)
-        self.requests_args.append(Args(args, kwargs))
-        return HTTP._request_with_timeout(url, self._request, *args, **kwargs)
+        self.requests_methods.append(http_method)
+        self.requests_args.append(kwargs)
+        return HTTP._request_with_timeout(http_method, url, self._request, **kwargs)
 
-    def do_get(self, url: str, *args: Any, **kwargs: Any) -> Response:
-        return self.do_request("GET", url, *args, **kwargs)
+    def do_get(self, url: str, **kwargs: Unpack[GetRequestKwargs]) -> Response:
+        return self.do_request("GET", url, **kwargs)
 
     @contextmanager
     def patch(self) -> Generator[None, None, None]:


Reviews:
user: login: jonathangreen
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-22T02:08:39Z
user: login: tdilauro
body: Looks good! 🔐🚀 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T19:09:46Z

Review comments:
diff_hunk: @@ -162,35 +216,37 @@ def set_quick_failure_settings(cls) -> None:
         cls.DEFAULT_REQUEST_TIMEOUT = 5
 
     @classmethod
-    def get_with_timeout(cls, url: str, *args: Any, **kwargs: Any) -> Response:
+    def get_with_timeout(cls, url: str, **kwargs: Unpack[GetRequestKwargs]) -> Response:
         """Make a GET request with timeout handling."""
-        return cls.request_with_timeout("GET", url, *args, **kwargs)
+        return cls.request_with_timeout("GET", url, **kwargs)
 
     @classmethod
     def post_with_timeout(
-        cls, url: str, payload: str | Mapping[str, Any], *args: Any, **kwargs: Any
+        cls,
+        url: str,
+        **kwargs: Unpack[RequestKwargs],
path: src/palace/manager/util/http.py
user: login: jonathangreen
body: Functional change here, I dropped the `payload` arg / kwarg, and instead use the `data` kwarg. This brings our implementation into line with requests. I followed this change through everywhere we call this method.
created_at: 2025-03-22T02:03:04Z
author_association: MEMBER
diff_hunk: @@ -162,35 +216,37 @@ def set_quick_failure_settings(cls) -> None:
         cls.DEFAULT_REQUEST_TIMEOUT = 5
 
     @classmethod
-    def get_with_timeout(cls, url: str, *args: Any, **kwargs: Any) -> Response:
+    def get_with_timeout(cls, url: str, **kwargs: Unpack[GetRequestKwargs]) -> Response:
         """Make a GET request with timeout handling."""
-        return cls.request_with_timeout("GET", url, *args, **kwargs)
+        return cls.request_with_timeout("GET", url, **kwargs)
 
     @classmethod
     def post_with_timeout(
-        cls, url: str, payload: str | Mapping[str, Any], *args: Any, **kwargs: Any
+        cls,
+        url: str,
+        **kwargs: Unpack[RequestKwargs],
     ) -> Response:
         """Make a POST request with timeout handling."""
-        kwargs["data"] = payload
-        return cls.request_with_timeout("POST", url, *args, **kwargs)
+        return cls.request_with_timeout("POST", url, **kwargs)
 
     @classmethod
     def put_with_timeout(
-        cls, url: str, payload: str | Mapping[str, Any], *args: Any, **kwargs: Any
+        cls,
+        url: str,
+        **kwargs: Unpack[RequestKwargs],
     ) -> Response:
path: src/palace/manager/util/http.py
user: login: jonathangreen
body: Same as above, dropped `payload` and updated callers.
created_at: 2025-03-22T02:03:32Z
author_association: MEMBER
diff_hunk: @@ -199,10 +255,11 @@ def request_with_timeout(
     @classmethod
     def _request_with_timeout(
         cls,
+        http_method: str,
         url: str,
         make_request_with: Callable[..., Response],
-        *args: Any,
-        **kwargs: Any,
+        process_response_with: _ProcessResponseCallable | None = None,
+        **kwargs: Unpack[RequestKwargs],
     ) -> Response:
path: src/palace/manager/util/http.py
user: login: jonathangreen
body: This doesn't get called directly much, its is mostly used within this class, but I changed around the parameter order to be consistent with the other methods. This required some changes in tests.
created_at: 2025-03-22T02:04:33Z
author_association: MEMBER
diff_hunk: @@ -230,39 +286,24 @@ def _request_with_timeout(
         )
         backoff_factor: float = float(kwargs.pop("backoff_factor", 1.0))
 
-        # Unicode data can't be sent over the wire. Convert it to UTF-8.
-        if "data" in kwargs and isinstance(kwargs["data"], str):
-            kwdata: str = kwargs["data"]
-            kwargs["data"] = kwdata.encode("utf8")
-
path: src/palace/manager/util/http.py
user: login: jonathangreen
body: This may have been true at one point in python 2, but its not in python 3. Requests is happy to accept `str`.
created_at: 2025-03-22T02:05:16Z
author_association: MEMBER
diff_hunk: @@ -230,39 +286,24 @@ def _request_with_timeout(
         )
         backoff_factor: float = float(kwargs.pop("backoff_factor", 1.0))
 
-        # Unicode data can't be sent over the wire. Convert it to UTF-8.
-        if "data" in kwargs and isinstance(kwargs["data"], str):
-            kwdata: str = kwargs["data"]
-            kwargs["data"] = kwdata.encode("utf8")
-
         # Set a user-agent if not already present
         version = (
             manager.__version__
             if manager.__version__
             else cls.DEFAULT_USER_AGENT_VERSION
         )
-        ua_header = {"User-Agent": f"Palace Manager/{version}"}
-        headers = ua_header | (kwargs.get("headers") or {})
-
-        # Make sure headers are encoded as utf-8
-        kwargs["headers"] = {
-            k.encode() if isinstance(k, str) else k: (
-                v.encode() if isinstance(v, str) else v
-            )
-            for k, v in headers.items()
-        }
path: src/palace/manager/util/http.py
user: login: jonathangreen
body: Again, requests is happy to accept headers as `str`, so no need to encode them to `bytes` ourselves.
created_at: 2025-03-22T02:05:56Z
author_association: MEMBER
diff_hunk: @@ -230,39 +286,24 @@ def _request_with_timeout(
         )
         backoff_factor: float = float(kwargs.pop("backoff_factor", 1.0))
 
-        # Unicode data can't be sent over the wire. Convert it to UTF-8.
-        if "data" in kwargs and isinstance(kwargs["data"], str):
-            kwdata: str = kwargs["data"]
-            kwargs["data"] = kwdata.encode("utf8")
-
         # Set a user-agent if not already present
         version = (
             manager.__version__
             if manager.__version__
             else cls.DEFAULT_USER_AGENT_VERSION
         )
-        ua_header = {"User-Agent": f"Palace Manager/{version}"}
-        headers = ua_header | (kwargs.get("headers") or {})
-
-        # Make sure headers are encoded as utf-8
-        kwargs["headers"] = {
-            k.encode() if isinstance(k, str) else k: (
-                v.encode() if isinstance(v, str) else v
-            )
-            for k, v in headers.items()
-        }
+        headers: dict[str, str | None] = {"User-Agent": f"Palace Manager/{version}"}
+        if (additional_headers := kwargs.get("headers")) is not None:
+            headers.update(additional_headers)
+        kwargs["headers"] = headers
 
         try:
             if verbose:
                 logging.info(
-                    "Sending request to %s: args %r kwargs %r", url, args, kwargs
+                    f"Sending {http_method} request to {url}: kwargs {kwargs!r}",
+                    url,
+                    kwargs,
                 )
-            if len(args) == 1:
-                # requests.request takes two positional arguments,
-                # an HTTP method and a URL. In most cases, the URL
-                # gets added on here. But if you do pass in both
-                # arguments, it will still work.
-                args = args + (url,)
path: src/palace/manager/util/http.py
user: login: jonathangreen
body: This ugly `arg` mangling isn't necessary, now that the functions parameter order is changed.
created_at: 2025-03-22T02:06:47Z
author_association: MEMBER

PR comments:
user: login: codecov[bot]
created_at: 2025-03-22T02:05:53Z
body: ## [Codecov](https://app.codecov.io/gh/ThePalaceProject/circulation/pull/2366?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ThePalaceProject) Report
Attention: Patch coverage is `91.52542%` with `5 lines` in your changes missing coverage. Please review.
> Project coverage is 91.31%. Comparing base [(`edcb365`)](https://app.codecov.io/gh/ThePalaceProject/circulation/commit/edcb365ae9beecd29e40cc2f844031f7f93ad723?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ThePalaceProject) to head [(`fb19d20`)](https://app.codecov.io/gh/ThePalaceProject/circulation/commit/fb19d20b4ec7ff55c62dcb0122686d2fb0c89487?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ThePalaceProject).
> Report is 3 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/ThePalaceProject/circulation/pull/2366?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ThePalaceProject) | Patch % | Lines |
|---|---|---|
| [src/palace/manager/util/http.py](https://app.codecov.io/gh/ThePalaceProject/circulation/pull/2366?src=pr&el=tree&filepath=src%2Fpalace%2Fmanager%2Futil%2Fhttp.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ThePalaceProject#diff-c3JjL3BhbGFjZS9tYW5hZ2VyL3V0aWwvaHR0cC5weQ==) | 90.90% | [1 Missing and 3 partials :warning: ](https://app.codecov.io/gh/ThePalaceProject/circulation/pull/2366?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ThePalaceProject) |
| [src/palace/manager/sqlalchemy/model/resource.py](https://app.codecov.io/gh/ThePalaceProject/circulation/pull/2366?src=pr&el=tree&filepath=src%2Fpalace%2Fmanager%2Fsqlalchemy%2Fmodel%2Fresource.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ThePalaceProject#diff-c3JjL3BhbGFjZS9tYW5hZ2VyL3NxbGFsY2hlbXkvbW9kZWwvcmVzb3VyY2UucHk=) | 0.00% | [1 Missing :warning: ](https://app.codecov.io/gh/ThePalaceProject/circulation/pull/2366?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ThePalaceProject) |

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##             main    #2366   +/-   ##
=======================================
  Coverage   91.31%   91.31%           
=======================================
  Files         377      377           
  Lines       41490    41505   +15     
  Branches     8883     8883           
=======================================
+ Hits        37885    37901   +16     
+ Misses       2343     2339    -4     
- Partials     1262     1265    +3     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/ThePalaceProject/circulation/pull/2366?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ThePalaceProject).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ThePalaceProject).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: jonathangreen
created_at: 2025-03-22T02:10:05Z
body: The tests failing here are the unrelated google drive tests that are failing elsewhere due to rate limits, so I think this is ready to go. 
author_association: MEMBER