typed UnsupportedSourceError replaces pipeline string-marker matching
This commit is contained in:
parent
57e89426ea
commit
ed36cc2230
7 changed files with 51 additions and 31 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
7
haiku_rag_slim/haiku/rag/client/exceptions.py
Normal file
7
haiku_rag_slim/haiku/rag/client/exceptions.py
Normal 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.
|
||||||
|
"""
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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}")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue