fix(api): force dict for form data, drop Authorization on presigned upload

httpx.AsyncClient rejects requests whose body stream is a sync IteratorByteStream.
That stream is what httpx builds whenever you pass data as a list of tuples,
even when files are present. Convert data to a dict before delegating to httpx
(duplicate keys collapse to a list value, which httpx still encodes correctly).

The presigned S3 upload URL embeds its own signature in the query string and
returns InvalidArgument: Unsupported Authorization Type if any extra
Authorization header tags along. Build the request explicitly and drop the
client-wide Basic auth header before sending whenever url_override is set.

Tested end-to-end against BookFusion via the calibre-web-automated proxy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Fguarien 2026-04-25 13:43:46 +02:00
parent 22950370de
commit 56b2a2cafa
2 changed files with 27 additions and 2 deletions

View file

@ -14,6 +14,11 @@ COPY pyproject.toml README.md ./
COPY src ./src
RUN pip install --upgrade pip && pip install .
# httpx 0.28 ships a regression that raises
# "Attempted to send an sync request with an AsyncClient instance"
# on multipart POSTs (used by bookfusion's /uploads/init). Pin to 0.27.x
# until the upstream fix lands.
RUN pip install --no-cache-dir "httpx>=0.27,<0.28"
# Non-root user for read-only library access.
RUN useradd -r -u 1000 -m bookfusion

View file

@ -139,14 +139,34 @@ class BookFusionClient:
expect_json: bool = True,
) -> Any:
url = url_override or self._url(path)
# httpx builds an IteratorByteStream (sync) whenever `data` is a list
# of tuples — even when files are present — and AsyncClient rejects
# those at send time. We must always pass a dict; duplicate keys are
# collapsed to a list value, which httpx encodes as repeated fields
# (form-urlencoded or multipart, depending on `files`).
req_data: Any = data
if data is not None:
req_data = {}
for k, v in data:
if k in req_data:
existing = req_data[k]
req_data[k] = existing + [v] if isinstance(existing, list) else [existing, v]
else:
req_data[k] = v
try:
resp = await self._client.request(
req = self._client.build_request(
method,
url,
params=params,
data=data,
data=req_data,
files=files,
)
# Presigned upload URLs (e.g. S3) embed their own signature in
# the query string and reject any extra Authorization header.
# Our BookFusion-wide Basic auth must not leak there.
if url_override:
req.headers.pop("Authorization", None)
resp = await self._client.send(req)
except httpx.HTTPError as exc:
raise BookFusionError(f"Network error calling {method} {url}: {exc}") from exc