Large files buffered entirely in RAM can OOM workers. Add max_file_size to source config (default None = no limit). FS checks stat().st_size before read_bytes(). HTTP and WebDAV issue a HEAD request before GET when a limit is configured. S3 checks the size from the existing head_async() call before get_async(). FileTooLargeError is classified as PermanentError so oversized files go straight to the DLQ instead of retrying.
184 lines
6.7 KiB
Python
184 lines
6.7 KiB
Python
import hashlib
|
|
import logging
|
|
from collections.abc import AsyncIterator
|
|
from datetime import UTC, datetime
|
|
from urllib.parse import urlparse
|
|
|
|
import httpx
|
|
|
|
from haiku.rag.ingester.sources.base import (
|
|
FetchResult,
|
|
RevisionSnapshot,
|
|
SourceEvent,
|
|
SourceEventKind,
|
|
check_file_size,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _extract_revision(headers: httpx.Headers) -> tuple[str | None, dict[str, str]]:
|
|
"""Return (canonical_revision, extras). ETag is the stronger validator
|
|
and is the canonical revision when present; Last-Modified backs it up.
|
|
Last-Modified always goes into extras as separate per-source provenance
|
|
(some pipelines want both signals)."""
|
|
extra: dict[str, str] = {}
|
|
etag = (headers.get("etag") or "").strip('"').strip()
|
|
last_modified = (headers.get("last-modified") or "").strip()
|
|
if last_modified:
|
|
extra["last_modified"] = last_modified
|
|
revision = etag or last_modified or None
|
|
return revision, extra
|
|
|
|
|
|
class HTTPSource:
|
|
def __init__(
|
|
self,
|
|
*,
|
|
source_id: str,
|
|
urls: list[str] | None = None,
|
|
headers: dict[str, str] | None = None,
|
|
transport: httpx.AsyncBaseTransport | None = None,
|
|
max_file_size: int | None = None,
|
|
) -> None:
|
|
self.source_id = source_id
|
|
self.urls = list(urls or [])
|
|
self.headers = dict(headers or {})
|
|
self._http = httpx.AsyncClient(headers=self.headers, transport=transport)
|
|
self._max_file_size = max_file_size
|
|
|
|
def supports(self, uri: str) -> bool:
|
|
return urlparse(uri).scheme in ("http", "https")
|
|
|
|
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."""
|
|
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:
|
|
if self._max_file_size is not None:
|
|
head = await self._http.head(uri)
|
|
content_length = head.headers.get("content-length")
|
|
if content_length is not None:
|
|
check_file_size(int(content_length), self._max_file_size, uri)
|
|
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,
|
|
since: RevisionSnapshot | None = None,
|
|
*,
|
|
known_uris: set[str] | None = None,
|
|
) -> AsyncIterator[SourceEvent]:
|
|
# HTTP has no listing concept — discover() only reports on what is
|
|
# currently configured in self.urls. URLs that were previously
|
|
# known to this source (sync_state) but aren't in the current
|
|
# config emit DELETE so the poller can clean up alongside the
|
|
# in-source 410 signal.
|
|
#
|
|
# 410 Gone is the one real source-side deletion signal: the origin
|
|
# explicitly says "permanently gone". 404 and other failures are
|
|
# ambiguous (transient outage, misconfigured URL, auth blip), so we
|
|
# fall back to UPSERT with no revision and let the worker decide
|
|
# via GET.
|
|
snapshot: dict[str, str] = dict(since) if since else {}
|
|
known = known_uris or set()
|
|
now = datetime.now(UTC)
|
|
configured = set(self.urls)
|
|
|
|
for url in self.urls:
|
|
try:
|
|
head = await self._http.head(url)
|
|
except httpx.TransportError as exc:
|
|
logger.debug(
|
|
"HEAD %s failed (%s); emitting UPSERT with no revision",
|
|
url,
|
|
exc,
|
|
)
|
|
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
|
|
elif revision is None and url in known:
|
|
# Server provides no revision header (no ETag, no
|
|
# Last-Modified). We can't detect changes, but the URL was
|
|
# already ingested — skip rather than re-fetch every sweep. A
|
|
# real change is only picked up if the server starts returning
|
|
# revision headers or the operator forces a re-ingest.
|
|
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,
|
|
# removing a URL from config leaves the document and sync_state
|
|
# indefinitely.
|
|
for url in known - configured:
|
|
yield SourceEvent(
|
|
source_id=self.source_id,
|
|
uri=url,
|
|
kind=SourceEventKind.DELETE,
|
|
revision=None,
|
|
discovered_at=now,
|
|
)
|