typed UnsupportedSourceError replaces pipeline string-marker matching

This commit is contained in:
Yiorgis Gozadinos 2026-05-25 12:31:31 +03:00
parent 57e89426ea
commit ed36cc2230
No known key found for this signature in database
7 changed files with 51 additions and 31 deletions

View file

@ -5,6 +5,7 @@ from urllib.parse import unquote, urlparse
import logfire import logfire
from haiku.rag.client.exceptions import UnsupportedSourceError
from haiku.rag.client.processing import ( from haiku.rag.client.processing import (
ensure_chunks_embedded, ensure_chunks_embedded,
get_extension_from_content_type_or_url, get_extension_from_content_type_or_url,
@ -226,7 +227,7 @@ async def _ingest_fetch_result(
result.uri, result.content_type result.uri, result.content_type
) )
if file_extension not in converter.supported_extensions: if file_extension not in converter.supported_extensions:
raise ValueError( raise UnsupportedSourceError(
f"Unsupported content type/extension: {result.content_type}/{file_extension}" f"Unsupported content type/extension: {result.content_type}/{file_extension}"
) )
@ -338,7 +339,7 @@ async def create_document_from_source(
) )
if local_path.is_dir(): if local_path.is_dir():
if uri is not None: if uri is not None:
raise ValueError( raise UnsupportedSourceError(
"uri override is not supported for directory sources; each file " "uri override is not supported for directory sources; each file "
"produces its own document with its own auto-derived URI." "produces its own document with its own auto-derived URI."
) )
@ -359,13 +360,15 @@ async def create_document_from_source(
return documents return documents
if not local_path.exists(): if not local_path.exists():
raise ValueError(f"File does not exist: {local_path}") raise UnsupportedSourceError(f"File does not exist: {local_path}")
# Match the old _create_document_from_file behaviour: fail fast on # Match the old _create_document_from_file behaviour: fail fast on
# unsupported extension before reading any bytes. # unsupported extension before reading any bytes.
converter = get_converter(client._config) converter = get_converter(client._config)
if local_path.suffix.lower() not in converter.supported_extensions: if local_path.suffix.lower() not in converter.supported_extensions:
raise ValueError(f"Unsupported file extension: {local_path.suffix}") raise UnsupportedSourceError(
f"Unsupported file extension: {local_path.suffix}"
)
# Single resource — resolve the right Source adapter for this URI. # Single resource — resolve the right Source adapter for this URI.
fetcher = resolve_fetcher(source_str, storage_options=storage_options) fetcher = resolve_fetcher(source_str, storage_options=storage_options)

View file

@ -0,0 +1,7 @@
class UnsupportedSourceError(ValueError):
"""A source URI or file cannot be ingested and never will be on a retry —
unsupported extension, unsupported content type, missing file, malformed
S3 URI, etc. ``ValueError`` for backward compatibility with callers that
used to catch a plain ValueError; the ingester pipeline catches the
specific type to classify as ``PermanentError`` without string matching.
"""

View file

@ -6,6 +6,7 @@ from urllib.parse import urlparse
import httpx import httpx
from haiku.rag.client.exceptions import UnsupportedSourceError
from haiku.rag.config import AppConfig from haiku.rag.config import AppConfig
from haiku.rag.converters import get_converter from haiku.rag.converters import get_converter
from haiku.rag.store.models.chunk import Chunk from haiku.rag.store.models.chunk import Chunk
@ -100,9 +101,9 @@ async def convert(
# Path object - convert file directly # Path object - convert file directly
if isinstance(source, Path): if isinstance(source, Path):
if not source.exists(): if not source.exists():
raise ValueError(f"File does not exist: {source}") raise UnsupportedSourceError(f"File does not exist: {source}")
if source.suffix.lower() not in converter.supported_extensions: if source.suffix.lower() not in converter.supported_extensions:
raise ValueError(f"Unsupported file extension: {source.suffix}") raise UnsupportedSourceError(f"Unsupported file extension: {source.suffix}")
effective_uri = source_uri or source.absolute().as_uri() effective_uri = source_uri or source.absolute().as_uri()
doc = await _convert_file(source, effective_uri) doc = await _convert_file(source, effective_uri)
_warn_if_descriptions_missing(config, doc, str(source)) _warn_if_descriptions_missing(config, doc, str(source))
@ -123,7 +124,7 @@ async def convert(
) )
if file_extension not in converter.supported_extensions: if file_extension not in converter.supported_extensions:
raise ValueError( raise UnsupportedSourceError(
f"Unsupported content type/extension: {content_type}/{file_extension}" f"Unsupported content type/extension: {content_type}/{file_extension}"
) )
@ -146,9 +147,11 @@ async def convert(
# file:// URI # file:// URI
file_path = Path(parsed.path) file_path = Path(parsed.path)
if not file_path.exists(): if not file_path.exists():
raise ValueError(f"File does not exist: {file_path}") raise UnsupportedSourceError(f"File does not exist: {file_path}")
if file_path.suffix.lower() not in converter.supported_extensions: if file_path.suffix.lower() not in converter.supported_extensions:
raise ValueError(f"Unsupported file extension: {file_path.suffix}") raise UnsupportedSourceError(
f"Unsupported file extension: {file_path.suffix}"
)
effective_uri = source_uri or file_path.absolute().as_uri() effective_uri = source_uri or file_path.absolute().as_uri()
doc = await _convert_file(file_path, effective_uri) doc = await _convert_file(file_path, effective_uri)
_warn_if_descriptions_missing(config, doc, str(file_path)) _warn_if_descriptions_missing(config, doc, str(file_path))

View file

@ -2,6 +2,7 @@ from collections.abc import Iterable
from pathlib import Path from pathlib import Path
from urllib.parse import urlparse from urllib.parse import urlparse
from haiku.rag.client.exceptions import UnsupportedSourceError
from haiku.rag.ingester.sources.base import Source from haiku.rag.ingester.sources.base import Source
from haiku.rag.ingester.sources.fs import FSSource from haiku.rag.ingester.sources.fs import FSSource
from haiku.rag.ingester.sources.http import HTTPSource from haiku.rag.ingester.sources.http import HTTPSource
@ -36,7 +37,7 @@ def resolve_fetcher(
if scheme == "s3": if scheme == "s3":
bucket = urlparse(uri).netloc bucket = urlparse(uri).netloc
if not bucket: if not bucket:
raise ValueError(f"Invalid S3 URI: {uri}") raise UnsupportedSourceError(f"Invalid S3 URI: {uri}")
return S3Source(uri=f"s3://{bucket}/", storage_options=storage_options) return S3Source(uri=f"s3://{bucket}/", storage_options=storage_options)
raise ValueError(f"No source adapter for URI scheme {scheme!r}: {uri}") raise UnsupportedSourceError(f"No source adapter for URI scheme {scheme!r}: {uri}")

View file

@ -4,6 +4,7 @@ from collections.abc import AsyncIterator
from datetime import UTC, datetime from datetime import UTC, datetime
from urllib.parse import urlparse from urllib.parse import urlparse
from haiku.rag.client.exceptions import UnsupportedSourceError
from haiku.rag.ingester.sources.base import ( from haiku.rag.ingester.sources.base import (
FetchResult, FetchResult,
RevisionSnapshot, RevisionSnapshot,
@ -19,7 +20,7 @@ from haiku.rag.ingester.sources.filter import (
def _parse_s3_uri(uri: str) -> tuple[str, str]: def _parse_s3_uri(uri: str) -> tuple[str, str]:
parsed = urlparse(uri) parsed = urlparse(uri)
if parsed.scheme != "s3" or not parsed.netloc: if parsed.scheme != "s3" or not parsed.netloc:
raise ValueError(f"Invalid S3 URI: {uri}") raise UnsupportedSourceError(f"Invalid S3 URI: {uri}")
return parsed.netloc, parsed.path.lstrip("/") return parsed.netloc, parsed.path.lstrip("/")
@ -27,7 +28,7 @@ def _parse_s3_object_uri(uri: str) -> tuple[str, str]:
"""Like _parse_s3_uri but rejects bucket-only URIs (no key).""" """Like _parse_s3_uri but rejects bucket-only URIs (no key)."""
bucket, key = _parse_s3_uri(uri) bucket, key = _parse_s3_uri(uri)
if not key: if not key:
raise ValueError(f"Invalid S3 URI: {uri}") raise UnsupportedSourceError(f"Invalid S3 URI: {uri}")
return bucket, key return bucket, key

View file

@ -6,22 +6,13 @@ import httpx
import logfire import logfire
from pydantic import BaseModel from pydantic import BaseModel
from haiku.rag.client.exceptions import UnsupportedSourceError
from haiku.rag.ingester.exceptions import PermanentError, TransientError from haiku.rag.ingester.exceptions import PermanentError, TransientError
from haiku.rag.ingester.queue.models import Job, JobOp from haiku.rag.ingester.queue.models import Job, JobOp
if TYPE_CHECKING: if TYPE_CHECKING:
from haiku.rag.client import HaikuRAG from haiku.rag.client import HaikuRAG
# ValueError messages from create_document_from_source that mean "this job
# will never succeed". Anything else from ValueError defaults to transient.
_PERMANENT_VALUE_MARKERS = (
"Unsupported file extension",
"Unsupported content type",
"Invalid S3 URI",
"File does not exist",
"uri override is not supported",
)
class JobResult(BaseModel): class JobResult(BaseModel):
"""What the worker needs after a successful job: enough metadata to """What the worker needs after a successful job: enough metadata to
@ -39,11 +30,17 @@ def _classify(exc: BaseException) -> Exception:
if isinstance(exc, PermanentError | TransientError): if isinstance(exc, PermanentError | TransientError):
return exc return exc
# UnsupportedSourceError is the typed signal from client/* that the
# source will never ingest successfully on a retry (bad URI scheme,
# missing file, unsupported extension, etc.).
if isinstance(exc, UnsupportedSourceError):
return PermanentError(str(exc))
if isinstance(exc, ValueError): if isinstance(exc, ValueError):
message = str(exc) # Some downstream libraries (e.g. docling) raise plain ValueError
if any(marker in message for marker in _PERMANENT_VALUE_MARKERS): # for "couldn't parse this file"; default to transient so the queue
return PermanentError(message) # retries up to max_attempts in case the issue is intermittent.
return TransientError(message) return TransientError(str(exc))
if isinstance(exc, httpx.HTTPStatusError): if isinstance(exc, httpx.HTTPStatusError):
status = exc.response.status_code status = exc.response.status_code

View file

@ -104,8 +104,10 @@ async def test_delete_is_noop_when_document_missing():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_unsupported_extension_classified_permanent(): async def test_unsupported_extension_classified_permanent():
from haiku.rag.client.exceptions import UnsupportedSourceError
client = _mock_client() client = _mock_client()
client.create_document_from_source.side_effect = ValueError( client.create_document_from_source.side_effect = UnsupportedSourceError(
"Unsupported file extension: .xyz" "Unsupported file extension: .xyz"
) )
@ -115,8 +117,10 @@ async def test_unsupported_extension_classified_permanent():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_unsupported_content_type_classified_permanent(): async def test_unsupported_content_type_classified_permanent():
from haiku.rag.client.exceptions import UnsupportedSourceError
client = _mock_client() client = _mock_client()
client.create_document_from_source.side_effect = ValueError( client.create_document_from_source.side_effect = UnsupportedSourceError(
"Unsupported content type/extension: application/octet-stream/.bin" "Unsupported content type/extension: application/octet-stream/.bin"
) )
with pytest.raises(PermanentError): with pytest.raises(PermanentError):
@ -125,8 +129,10 @@ async def test_unsupported_content_type_classified_permanent():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_invalid_s3_uri_classified_permanent(): async def test_invalid_s3_uri_classified_permanent():
from haiku.rag.client.exceptions import UnsupportedSourceError
client = _mock_client() client = _mock_client()
client.create_document_from_source.side_effect = ValueError( client.create_document_from_source.side_effect = UnsupportedSourceError(
"Invalid S3 URI: s3:///bad" "Invalid S3 URI: s3:///bad"
) )
with pytest.raises(PermanentError): with pytest.raises(PermanentError):
@ -135,8 +141,10 @@ async def test_invalid_s3_uri_classified_permanent():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_missing_file_classified_permanent(): async def test_missing_file_classified_permanent():
from haiku.rag.client.exceptions import UnsupportedSourceError
client = _mock_client() client = _mock_client()
client.create_document_from_source.side_effect = ValueError( client.create_document_from_source.side_effect = UnsupportedSourceError(
"File does not exist: /nope" "File does not exist: /nope"
) )
with pytest.raises(PermanentError): with pytest.raises(PermanentError):