Skip to content

Commit 9c247a9

Browse files
authored
Move the HTTP transport from httpx to httpx2 (#3)
httpx has seen no commit since February 2026. httpx2 is the fork its original author now maintains under Pydantic's stewardship, published from pydantic/httpx2 under the same BSD-3-Clause licence; it forked httpx 0.28.1 and keeps the public API, so this is a rename rather than a rewrite. The wider ecosystem has already moved: the Anthropic Python SDK requires httpx2 as of its 1.0 release. This is a breaking change for callers, because litfetch's HTTP types are part of its surface. The two packages install side by side but their classes are distinct, so an object cannot cross between them: `Session.client` now returns an `httpx2.AsyncClient`, `client_factory` must build one, `Session.get` returns an `httpx2.Response`, and the errors litfetch raises are `httpx2` exceptions that an `except httpx` clause no longer catches. Callers migrate by renaming their own imports. Version bumped to 0.3.0 accordingly. Two visible behaviour changes come with the fork: TLS is verified against the operating system trust store via truststore rather than certifi's bundle, and deprecation warnings are shown by default. ADR 0001 is left untouched. It is a dated record of why a Session owns one client and the per-host pacing state, and that decision is unaffected by which package supplies the client.
1 parent 2b97838 commit 9c247a9

20 files changed

Lines changed: 213 additions & 189 deletions

docs/api.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -493,7 +493,7 @@ class Session:
493493
def __init__(
494494
self,
495495
*,
496-
client_factory: Callable[[], httpx.AsyncClient] | None = None,
496+
client_factory: Callable[[], httpx2.AsyncClient] | None = None,
497497
retry: RetryPolicy = <default>,
498498
timeout: float = 30.0,
499499
contact: str | None = None,
@@ -502,9 +502,9 @@ class Session:
502502
async def __aexit__(self, *exc) -> None # closes it (a scope leaves it open)
503503
def scope(self) -> Session # child with its own cache; see below
504504
@property
505-
def client(self) -> httpx.AsyncClient # escape hatch; valid only in-context
505+
def client(self) -> httpx2.AsyncClient # escape hatch; valid only in-context
506506
async def get(self, url, *, params=None, headers=None, rate=Rate.DEFAULT,
507-
follow_redirects=False) -> httpx.Response
507+
follow_redirects=False) -> httpx2.Response
508508
# operations: fetch_body, list_files, fetch_file, resolve_access, related_ids
509509
```
510510

@@ -514,7 +514,7 @@ or CA-cert configuration; the default builds a client with a litfetch
514514
`User-Agent` and `timeout`. `contact` (an email) is the caller's polite-pool
515515
identity — see [Contact](#contact) below. `get` paces per `rate` then issues a
516516
retrying GET (see [`RetryPolicy`](#retrypolicy)) — and, inside a `scope`, serves
517-
a repeat request from cache; `client` exposes the raw `httpx.AsyncClient` for
517+
a repeat request from cache; `client` exposes the raw `httpx2.AsyncClient` for
518518
what `get` doesn't cover (POST, streaming). `follow_redirects` is off by default
519519
(an API move should surface, not be chased silently); `fetch_file` downloads
520520
pass it through to follow publisher PDF redirects.
@@ -557,7 +557,7 @@ class Http(Protocol):
557557
headers: Mapping[str, str] | None = None,
558558
rate: Rate = Rate.DEFAULT,
559559
follow_redirects: bool = False,
560-
) -> httpx.Response
560+
) -> httpx2.Response
561561
```
562562

563563
The one-method surface a source or resolver depends on. `Session` implements it.

docs/institutional-access.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ async def _rewrite(request):
5050
request.headers['Host'] = new
5151

5252
def factory():
53-
return httpx.AsyncClient(headers={'Cookie': cookie}, event_hooks={'request': [_rewrite]})
53+
return httpx2.AsyncClient(headers={'Cookie': cookie}, event_hooks={'request': [_rewrite]})
5454

5555
async with litfetch.Session(client_factory=factory) as entitled:
5656
...

docs/source-expansion-plan.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,12 +98,12 @@ without threading a shared record (resolving the open question below).
9898
(generalising `ElsevierFetcher` to any publisher) is deferred: it needs TDM
9999
token handling (see EZproxy/credentials below), and unentitled links 403.
100100
- **doi.org resolve****deferred.** Marginal coverage over Unpaywall's
101-
`best_oa_location` for real friction: doi.org 30x-redirects (httpx doesn't
101+
`best_oa_location` for real friction: doi.org 30x-redirects (httpx2 doesn't
102102
follow by default, and `Http.get` doesn't expose the option), and it is
103103
fetch-to-discover (a GET to classify by `content-type`, then a second GET to
104104
download — most redirects land on HTML anyway). If revisited: add
105105
`follow_redirects` to `Http.get` (opt-in) rather than enabling it globally
106-
(httpx keeps custom auth headers across cross-origin redirects — a key-leak
106+
(httpx2 keeps custom auth headers across cross-origin redirects — a key-leak
107107
footgun).
108108

109109
### 4. Opportunistic / later

litfetch/_http.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from collections.abc import Mapping
2323
from typing import Protocol
2424

25-
import httpx
25+
import httpx2
2626

2727
DEFAULT_TIMEOUT = 30.0
2828
# Base User-Agent, no contact. A caller who sets Session(contact=...) gets a
@@ -94,7 +94,7 @@ async def get(
9494
headers: Mapping[str, str] | None = None,
9595
rate: Rate = Rate.DEFAULT,
9696
follow_redirects: bool = False,
97-
) -> httpx.Response:
97+
) -> httpx2.Response:
9898
"""GET ``url``, paced per ``rate`` and retried per the session policy."""
9999
...
100100

@@ -103,7 +103,7 @@ async def get(
103103
class RetryPolicy:
104104
"""How :func:`get` retries a transient failure.
105105
106-
A transient failure is an ``httpx.TransportError`` (timeout, connection
106+
A transient failure is an ``httpx2.TransportError`` (timeout, connection
107107
reset) or a retryable status (429, 500, 502, 503, 504). Backoff is
108108
exponential with full jitter -- ``uniform(0, base_delay * 2**attempt)`` --
109109
capped at ``max_delay``; a 429/503 ``Retry-After`` in integer seconds
@@ -126,23 +126,23 @@ def __post_init__(self) -> None:
126126

127127

128128
async def get(
129-
client: httpx.AsyncClient,
129+
client: httpx2.AsyncClient,
130130
url: str,
131131
*,
132132
params: Mapping[str, str | int] | None = None,
133133
headers: Mapping[str, str] | None = None,
134134
retry: RetryPolicy = DEFAULT_RETRY,
135135
follow_redirects: bool = False,
136-
) -> httpx.Response:
136+
) -> httpx2.Response:
137137
"""GET ``url``, retrying a transient failure per ``retry``.
138138
139-
Retries an ``httpx.TransportError`` or a retryable status (see
139+
Retries an ``httpx2.TransportError`` or a retryable status (see
140140
:class:`RetryPolicy`) with backoff, then returns the final response --
141141
including a still-failing status, so the caller keeps its own status
142142
handling. Re-raises the last transport error when every attempt fails.
143143
144144
Args:
145-
client: The httpx client to issue the request on.
145+
client: The httpx2 client to issue the request on.
146146
url: The absolute URL to GET.
147147
params: Query parameters, if any.
148148
headers: Request headers, if any.
@@ -151,18 +151,18 @@ async def get(
151151
enable it to follow publisher PDF redirects).
152152
153153
Returns:
154-
The final :class:`httpx.Response` (a non-retryable status, or the last
154+
The final :class:`httpx2.Response` (a non-retryable status, or the last
155155
response after exhausting retries).
156156
157157
Raises:
158-
httpx.TransportError: If every attempt fails at the transport layer.
158+
httpx2.TransportError: If every attempt fails at the transport layer.
159159
"""
160160
for attempt in range(retry.max_attempts):
161161
last_attempt = attempt == retry.max_attempts - 1
162162
retry_after: float | None = None
163163
try:
164164
response = await client.get(url, params=params, headers=headers, follow_redirects=follow_redirects)
165-
except httpx.TransportError:
165+
except httpx2.TransportError:
166166
if last_attempt:
167167
raise
168168
else:
@@ -173,7 +173,7 @@ async def get(
173173
raise AssertionError('unreachable: the loop returns or raises on the last attempt')
174174

175175

176-
def _retry_after_seconds(response: httpx.Response) -> float | None:
176+
def _retry_after_seconds(response: httpx2.Response) -> float | None:
177177
"""Parse a ``Retry-After`` header as integer seconds; ``None`` otherwise.
178178
179179
The HTTP-date form is accepted by the spec but not used by the APIs

litfetch/crossref.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
import logging
1414

15-
import httpx
15+
import httpx2
1616

1717
from litfetch import _doi, _http
1818

@@ -39,7 +39,7 @@ async def fetch_work(doi: str, *, http: _http.Http, mailto: str | None = None) -
3939
params = {'mailto': mailto} if mailto else {}
4040
try:
4141
resp = await http.get(f'{_CROSSREF_BASE}/{_doi.encode_doi_path(doi)}', params=params)
42-
except httpx.HTTPError:
42+
except httpx2.HTTPError:
4343
logger.exception('Crossref lookup failed for %s', doi)
4444
return None
4545
if resp.status_code != 200:

litfetch/fetchers.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
from typing import Protocol
4444

4545
import defusedxml.ElementTree
46-
import httpx
46+
import httpx2
4747

4848
from litfetch import _doi, _http, artifacts, crossref, ids, semantic_scholar, unpaywall
4949

@@ -176,7 +176,7 @@ async def fetch_jats_xml(
176176
url = _pmc_versioned_xml_url(numeric, version)
177177
try:
178178
resp = await http.get(url)
179-
except httpx.HTTPError:
179+
except httpx2.HTTPError:
180180
logger.exception('PMC OA fetch failed for %s', url)
181181
continue
182182
if resp.status_code == 200:
@@ -251,7 +251,7 @@ async def _download(http: _http.Http, file: artifacts.File, *, what: str) -> art
251251
try:
252252
# Publisher PDF links commonly redirect (openURL -> content/pdf, ...); follow them.
253253
resp = await http.get(file.uri, follow_redirects=True)
254-
except httpx.HTTPError:
254+
except httpx2.HTTPError:
255255
logger.exception('%s fetch failed for %s', what, file.uri)
256256
return None
257257
if resp.status_code != 200:
@@ -350,7 +350,7 @@ async def _list_keys(self, http: _http.Http, prefix: str) -> list[tuple[str, int
350350
params['continuation-token'] = token
351351
try:
352352
resp = await http.get(f'{_PMC_S3_BASE}/', params=params)
353-
except httpx.HTTPError:
353+
except httpx2.HTTPError:
354354
logger.exception('PMC OA list failed for prefix %s', prefix)
355355
return keys
356356
if resp.status_code != 200:
@@ -396,7 +396,7 @@ async def fetch(
396396
url = f'{_EUROPE_PMC_BASE}/PMC{numeric}/fullTextXML'
397397
try:
398398
resp = await http.get(url)
399-
except httpx.HTTPError:
399+
except httpx2.HTTPError:
400400
logger.exception('Europe PMC fetch failed for %s', url)
401401
return None
402402
if resp.status_code != 200 or not resp.content:
@@ -446,7 +446,7 @@ async def fetch(
446446
return None
447447
try:
448448
resp = await http.get(link, headers={'X-ELS-APIKey': api_key, 'Accept': 'text/xml'})
449-
except httpx.HTTPError:
449+
except httpx2.HTTPError:
450450
logger.exception('Elsevier fetch failed for %s', link)
451451
return None
452452
if resp.status_code != 200 or not resp.content or not _elsevier_has_body(resp.content):
@@ -491,7 +491,7 @@ async def fetch(
491491
query = f'doi:{article_ids.doi}'
492492
try:
493493
resp = await http.get(_SPRINGER_BASE, params={'q': query, 'api_key': api_key})
494-
except httpx.HTTPError:
494+
except httpx2.HTTPError:
495495
logger.exception('Springer fetch failed for %s', article_ids.doi)
496496
return None
497497
if resp.status_code != 200 or not resp.content:
@@ -515,7 +515,7 @@ async def _fetch_impersonated(url: str, *, impersonate: str) -> bytes | None:
515515
"""GET ``url`` with a browser TLS fingerprint via curl_cffi.
516516
517517
bioRxiv's JATS host sits behind Cloudflare's fingerprint gate, which a plain
518-
httpx client trips; curl_cffi impersonates a real browser's TLS/HTTP-2
518+
httpx2 client trips; curl_cffi impersonates a real browser's TLS/HTTP-2
519519
fingerprint to pass it. Raises a clear error when the optional extra is
520520
absent; returns ``None`` on a transport error or non-200.
521521
"""
@@ -592,7 +592,7 @@ async def _jats_url(self, http: _http.Http, doi: str) -> str | None:
592592
url = f'{_BIORXIV_DETAILS_BASE}/{server}/{_doi.encode_doi_path(doi)}'
593593
try:
594594
resp = await http.get(url)
595-
except httpx.HTTPError:
595+
except httpx2.HTTPError:
596596
logger.exception('bioRxiv details lookup failed for %s', url)
597597
continue
598598
if resp.status_code != 200:
@@ -780,7 +780,7 @@ async def _springer_meta_pdf(http: _http.Http, doi: str, api_key: str) -> tuple[
780780
"""
781781
try:
782782
resp = await http.get(_SPRINGER_META_BASE, params={'q': f'doi:{doi}', 'api_key': api_key})
783-
except httpx.HTTPError:
783+
except httpx2.HTTPError:
784784
logger.exception('Springer Meta request failed for %s', doi)
785785
return None
786786
if resp.status_code != 200:

litfetch/relations.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
import logging
2121
from typing import NamedTuple
2222

23-
import httpx
23+
import httpx2
2424

2525
from litfetch import _doi, _http, crossref, ids
2626

@@ -83,7 +83,7 @@ async def _biorxiv_published(http: _http.Http, doi: str) -> str | None:
8383
url = f'{_BIORXIV_DETAILS_BASE}/{server}/{_doi.encode_doi_path(doi)}'
8484
try:
8585
resp = await http.get(url)
86-
except httpx.HTTPError:
86+
except httpx2.HTTPError:
8787
logger.exception('bioRxiv details lookup failed for %s', url)
8888
continue
8989
if resp.status_code != 200:

litfetch/resolvers.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
import logging
3131
from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence
3232

33-
import httpx
33+
import httpx2
3434

3535
from litfetch import _http, ids, semantic_scholar
3636

@@ -80,7 +80,7 @@ async def _get_json(
8080
"""GET ``url`` and parse JSON, logging and swallowing transport errors."""
8181
try:
8282
resp = await http.get(url, params=params, rate=rate)
83-
except httpx.HTTPError:
83+
except httpx2.HTTPError:
8484
logger.exception('%s request failed', context)
8585
return None
8686
if resp.status_code != 200:
@@ -125,7 +125,7 @@ async def _get_json_or_abandon(
125125
"""
126126
try:
127127
resp = await http.get(url, params=params, rate=rate)
128-
except httpx.HTTPError as e:
128+
except httpx2.HTTPError as e:
129129
raise _ChunkAbandonedError(f'{context}: transport failure') from e
130130
if resp.status_code in _http.RETRYABLE_STATUS: # survived retries: never answered
131131
raise _ChunkAbandonedError(f'{context}: HTTP {resp.status_code} after retries')

litfetch/semantic_scholar.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
import logging
1515

16-
import httpx
16+
import httpx2
1717

1818
from litfetch import _doi, _http, ids
1919

@@ -59,7 +59,7 @@ async def fetch_paper(
5959
rate = _http.Rate.S2_KEYED if api_key else _http.Rate.S2_UNKEYED
6060
try:
6161
resp = await http.get(f'{_PAPER_BASE}/{pid}', params={'fields': fields}, headers=headers, rate=rate)
62-
except httpx.HTTPError:
62+
except httpx2.HTTPError:
6363
logger.exception('Semantic Scholar request failed')
6464
return None
6565
if resp.status_code != 200:

0 commit comments

Comments
 (0)