Reuse httpx.AsyncClient across requests in HTTP and WebDAV sources

HTTPSource and WebDAVSource previously created a new AsyncClient for
every head(), fetch(), and discover() call — no connection reuse, TLS
renegotiation on every request, and connection pool churn at scale.

Create the client once in __init__ and reuse it for the lifetime of
the source. Add aclose() to both sources, called by PollerManager on
shutdown to cleanly close the connection pool.
This commit is contained in:
Chris McDonough 2026-06-01 07:00:15 -04:00
parent d5e5733f67
commit 07c5a97929
3 changed files with 96 additions and 101 deletions

View file

@ -96,6 +96,9 @@ class PollerManager:
await asyncio.gather(*self._tasks, return_exceptions=True)
self._tasks.clear()
self._started = False
for source in self.sources:
if hasattr(source, "aclose"):
await source.aclose()
@property
def pollers(self) -> list[BasePoller]:

View file

@ -39,48 +39,44 @@ class HTTPSource:
self.source_id = source_id
self.urls = list(urls or [])
self.headers = dict(headers or {})
# transport is for testing — production callers leave it None and httpx
# uses its real transport.
self._transport = transport
self._http = httpx.AsyncClient(headers=self.headers, transport=transport)
def supports(self, uri: str) -> bool:
return urlparse(uri).scheme in ("http", "https")
def _client(self) -> httpx.AsyncClient:
return httpx.AsyncClient(headers=self.headers, transport=self._transport)
async def aclose(self) -> None:
await self._http.aclose()
async def head(self, uri: str) -> str | None:
"""HEAD probe for the cheap revision short-circuit. Returns the
ETag (or Last-Modified) so an unchanged remote URL can skip the
full GET. None on HTTP error so the caller falls back to fetch();
network errors propagate and the worker's classifier handles them."""
async with self._client() as http:
response = await http.head(uri)
if response.is_error:
return None
response = await self._http.head(uri)
if response.is_error:
return None
revision, _ = _extract_revision(response.headers)
return revision
async def fetch(self, uri: str) -> FetchResult:
async with self._client() as http:
response = await http.get(uri)
response.raise_for_status()
body = response.content
content_type = (
response.headers.get("content-type", "application/octet-stream")
.split(";")[0]
.strip()
.lower()
)
revision, extra = _extract_revision(response.headers)
return FetchResult(
uri=uri,
body=body,
content_type=content_type,
content_hash=hashlib.md5(body, usedforsecurity=False).hexdigest(),
revision=revision,
extra_metadata=extra,
)
response = await self._http.get(uri)
response.raise_for_status()
body = response.content
content_type = (
response.headers.get("content-type", "application/octet-stream")
.split(";")[0]
.strip()
.lower()
)
revision, extra = _extract_revision(response.headers)
return FetchResult(
uri=uri,
body=body,
content_type=content_type,
content_hash=hashlib.md5(body, usedforsecurity=False).hexdigest(),
revision=revision,
extra_metadata=extra,
)
async def discover(
self,
@ -104,53 +100,52 @@ class HTTPSource:
now = datetime.now(UTC)
configured = set(self.urls)
async with self._client() as http:
for url in self.urls:
try:
head = await http.head(url)
except Exception:
yield SourceEvent(
source_id=self.source_id,
uri=url,
kind=SourceEventKind.UPSERT,
revision=None,
discovered_at=now,
)
continue
if head.status_code == 410:
yield SourceEvent(
source_id=self.source_id,
uri=url,
kind=SourceEventKind.DELETE,
revision=None,
discovered_at=now,
)
continue
if head.is_error:
yield SourceEvent(
source_id=self.source_id,
uri=url,
kind=SourceEventKind.UPSERT,
revision=None,
discovered_at=now,
)
continue
revision, _ = _extract_revision(head.headers)
if revision is not None and snapshot.get(url) == revision:
kind = SourceEventKind.UNCHANGED
else:
kind = SourceEventKind.UPSERT
for url in self.urls:
try:
head = await self._http.head(url)
except Exception:
yield SourceEvent(
source_id=self.source_id,
uri=url,
kind=kind,
revision=revision,
kind=SourceEventKind.UPSERT,
revision=None,
discovered_at=now,
)
continue
if head.status_code == 410:
yield SourceEvent(
source_id=self.source_id,
uri=url,
kind=SourceEventKind.DELETE,
revision=None,
discovered_at=now,
)
continue
if head.is_error:
yield SourceEvent(
source_id=self.source_id,
uri=url,
kind=SourceEventKind.UPSERT,
revision=None,
discovered_at=now,
)
continue
revision, _ = _extract_revision(head.headers)
if revision is not None and snapshot.get(url) == revision:
kind = SourceEventKind.UNCHANGED
else:
kind = SourceEventKind.UPSERT
yield SourceEvent(
source_id=self.source_id,
uri=url,
kind=kind,
revision=revision,
discovered_at=now,
)
# Anything previously known to this source that's no longer in
# config emits DELETE so delete_orphans can clean up. Without this,

View file

@ -189,41 +189,39 @@ class WebDAVSource:
supported_extensions=self.supported_extensions,
)
# transport is for testing — production callers leave it None.
self._transport = transport
def supports(self, uri: str) -> bool:
return uri.startswith(self.base_url)
def _client(self) -> httpx.AsyncClient:
auth = (
(self.username, self.password)
if self.username is not None and self.password is not None
else None
)
return httpx.AsyncClient(
auth=auth, headers=self.headers, transport=self._transport
self._http = httpx.AsyncClient(
auth=auth, headers=self.headers, transport=transport
)
def supports(self, uri: str) -> bool:
return uri.startswith(self.base_url)
async def aclose(self) -> None:
await self._http.aclose()
async def head(self, uri: str) -> str | None:
async with self._client() as http:
response = await http.request(
"PROPFIND",
uri,
headers={"Depth": "0", "Content-Type": "application/xml"},
content=_PROPFIND_BODY,
)
if response.is_error:
return None
entries = _parse_multistatus(response.content)
response = await self._http.request(
"PROPFIND",
uri,
headers={"Depth": "0", "Content-Type": "application/xml"},
content=_PROPFIND_BODY,
)
if response.is_error:
return None
entries = _parse_multistatus(response.content)
if not entries:
return None
return entries[0].revision
async def fetch(self, uri: str) -> FetchResult:
async with self._client() as http:
response = await http.get(uri)
response.raise_for_status()
body = response.content
response = await self._http.get(uri)
response.raise_for_status()
body = response.content
content_type = (
response.headers.get("content-type", "application/octet-stream")
.split(";")[0]
@ -261,15 +259,14 @@ class WebDAVSource:
now = datetime.now(UTC)
seen: set[str] = set()
async with self._client() as http:
response = await http.request(
"PROPFIND",
self.base_url,
headers={"Depth": "infinity", "Content-Type": "application/xml"},
content=_PROPFIND_BODY,
)
response.raise_for_status()
entries = _parse_multistatus(response.content)
response = await self._http.request(
"PROPFIND",
self.base_url,
headers={"Depth": "infinity", "Content-Type": "application/xml"},
content=_PROPFIND_BODY,
)
response.raise_for_status()
entries = _parse_multistatus(response.content)
for entry in entries:
if entry.is_collection: