Let httpx errors flow through DoclingServeClient
This commit is contained in:
parent
fdb73fc41f
commit
b491f767f3
5 changed files with 59 additions and 83 deletions
|
|
@ -52,10 +52,9 @@ def _classify(exc: BaseException) -> Exception:
|
||||||
return TransientError(f"HTTP {status}: {exc}")
|
return TransientError(f"HTTP {status}: {exc}")
|
||||||
return PermanentError(f"HTTP {status}: {exc}")
|
return PermanentError(f"HTTP {status}: {exc}")
|
||||||
|
|
||||||
if isinstance(
|
if isinstance(exc, httpx.TransportError):
|
||||||
exc,
|
# Umbrella for ConnectError, NetworkError, TimeoutException, ProtocolError,
|
||||||
httpx.ConnectError | httpx.ReadTimeout | httpx.WriteTimeout | httpx.PoolTimeout,
|
# ProxyError — every transport-layer failure that's worth retrying.
|
||||||
):
|
|
||||||
return TransientError(f"network: {exc}")
|
return TransientError(f"network: {exc}")
|
||||||
|
|
||||||
if isinstance(exc, asyncio.TimeoutError | TimeoutError | OSError):
|
if isinstance(exc, asyncio.TimeoutError | TimeoutError | OSError):
|
||||||
|
|
|
||||||
|
|
@ -118,50 +118,22 @@ class DoclingServeClient:
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Submit a task and poll until completion; fetch result as JSON.
|
"""Submit a task and poll until completion; fetch result as JSON.
|
||||||
|
|
||||||
Args:
|
httpx exceptions (ConnectError, HTTPStatusError, TimeoutException,
|
||||||
endpoint: The async endpoint path (e.g., "/v1/convert/file/async")
|
etc.) propagate so the ingester's pipeline classifier can route
|
||||||
files: Files to upload
|
4xx → PermanentError and 5xx/network → TransientError. ValueError
|
||||||
data: Form data parameters
|
is raised by `_submit_and_wait` when docling-serve reports a task
|
||||||
name: Name for error messages
|
failure or returns no task_id.
|
||||||
|
|
||||||
Returns:
|
|
||||||
The result dictionary from the completed task
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If the task fails or service is unavailable
|
|
||||||
"""
|
"""
|
||||||
headers = self._get_headers()
|
headers = self._get_headers()
|
||||||
base_url = self._pick_url()
|
base_url = self._pick_url()
|
||||||
|
async with self._httpx_client() as client:
|
||||||
try:
|
task_id = await self._submit_and_wait(
|
||||||
async with self._httpx_client() as client:
|
client, base_url, endpoint, files, data, headers, name
|
||||||
task_id = await self._submit_and_wait(
|
)
|
||||||
client, base_url, endpoint, files, data, headers, name
|
result_url = f"{base_url}/v1/result/{task_id}"
|
||||||
)
|
result_response = await client.get(result_url, headers=headers)
|
||||||
result_url = f"{base_url}/v1/result/{task_id}"
|
result_response.raise_for_status()
|
||||||
result_response = await client.get(result_url, headers=headers)
|
return result_response.json()
|
||||||
result_response.raise_for_status()
|
|
||||||
return result_response.json()
|
|
||||||
|
|
||||||
except httpx.ConnectError as e:
|
|
||||||
raise ValueError(
|
|
||||||
f"Could not connect to docling-serve at {base_url}. "
|
|
||||||
f"Ensure the service is running and accessible. Error: {e}"
|
|
||||||
) from e
|
|
||||||
except httpx.TimeoutException as e:
|
|
||||||
raise ValueError(
|
|
||||||
f"Request to docling-serve timed out after {self.timeout}s. Error: {e}"
|
|
||||||
) from e
|
|
||||||
except httpx.HTTPStatusError as e:
|
|
||||||
if e.response.status_code == 401:
|
|
||||||
raise ValueError(
|
|
||||||
"Authentication failed. Check your API key configuration."
|
|
||||||
) from e
|
|
||||||
raise ValueError(f"HTTP error from docling-serve: {e}") from e
|
|
||||||
except ValueError:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
raise ValueError(f"Failed to process via docling-serve: {e}") from e
|
|
||||||
|
|
||||||
async def submit_and_poll_zip(
|
async def submit_and_poll_zip(
|
||||||
self,
|
self,
|
||||||
|
|
@ -179,33 +151,11 @@ class DoclingServeClient:
|
||||||
"""
|
"""
|
||||||
headers = self._get_headers()
|
headers = self._get_headers()
|
||||||
base_url = self._pick_url()
|
base_url = self._pick_url()
|
||||||
|
async with self._httpx_client() as client:
|
||||||
try:
|
task_id = await self._submit_and_wait(
|
||||||
async with self._httpx_client() as client:
|
client, base_url, endpoint, files, data, headers, name
|
||||||
task_id = await self._submit_and_wait(
|
)
|
||||||
client, base_url, endpoint, files, data, headers, name
|
result_url = f"{base_url}/v1/result/{task_id}"
|
||||||
)
|
result_response = await client.get(result_url, headers=headers)
|
||||||
result_url = f"{base_url}/v1/result/{task_id}"
|
result_response.raise_for_status()
|
||||||
result_response = await client.get(result_url, headers=headers)
|
return result_response.content
|
||||||
result_response.raise_for_status()
|
|
||||||
return result_response.content
|
|
||||||
|
|
||||||
except httpx.ConnectError as e:
|
|
||||||
raise ValueError(
|
|
||||||
f"Could not connect to docling-serve at {base_url}. "
|
|
||||||
f"Ensure the service is running and accessible. Error: {e}"
|
|
||||||
) from e
|
|
||||||
except httpx.TimeoutException as e:
|
|
||||||
raise ValueError(
|
|
||||||
f"Request to docling-serve timed out after {self.timeout}s. Error: {e}"
|
|
||||||
) from e
|
|
||||||
except httpx.HTTPStatusError as e:
|
|
||||||
if e.response.status_code == 401:
|
|
||||||
raise ValueError(
|
|
||||||
"Authentication failed. Check your API key configuration."
|
|
||||||
) from e
|
|
||||||
raise ValueError(f"HTTP error from docling-serve: {e}") from e
|
|
||||||
except ValueError:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
raise ValueError(f"Failed to process via docling-serve: {e}") from e
|
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,27 @@ async def test_connect_error_classified_transient():
|
||||||
await run_job(client, _job())
|
await run_job(client, _job())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"exc_factory",
|
||||||
|
[
|
||||||
|
lambda: httpx.ConnectTimeout("slow connect"),
|
||||||
|
lambda: httpx.ReadTimeout("slow read"),
|
||||||
|
lambda: httpx.WriteTimeout("slow write"),
|
||||||
|
lambda: httpx.PoolTimeout("pool"),
|
||||||
|
lambda: httpx.ProxyError("proxy"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_transport_subclasses_classified_transient(exc_factory):
|
||||||
|
"""The classifier umbrellas on httpx.TransportError so every transport-
|
||||||
|
layer subclass routes to TransientError, not the generic 'unexpected'
|
||||||
|
fallback."""
|
||||||
|
client = _mock_client()
|
||||||
|
client.create_document_from_source.side_effect = exc_factory()
|
||||||
|
with pytest.raises(TransientError, match="network"):
|
||||||
|
await run_job(client, _job())
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_unknown_exception_classified_transient():
|
async def test_unknown_exception_classified_transient():
|
||||||
client = _mock_client()
|
client = _mock_client()
|
||||||
|
|
|
||||||
|
|
@ -443,7 +443,7 @@ class TestDoclingServeChunker:
|
||||||
converter = get_converter(Config)
|
converter = get_converter(Config)
|
||||||
doc = await converter.convert_text("# Test", name="test.md")
|
doc = await converter.convert_text("# Test", name="test.md")
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="Could not connect to docling-serve"):
|
with pytest.raises(httpx.ConnectError):
|
||||||
await chunker.chunk(doc)
|
await chunker.chunk(doc)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -459,13 +459,15 @@ class TestDoclingServeChunker:
|
||||||
converter = get_converter(Config)
|
converter = get_converter(Config)
|
||||||
doc = await converter.convert_text("# Test", name="test.md")
|
doc = await converter.convert_text("# Test", name="test.md")
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="timed out"):
|
with pytest.raises(httpx.TimeoutException):
|
||||||
await chunker.chunk(doc)
|
await chunker.chunk(doc)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@patch("haiku.rag.providers.docling_serve.httpx.AsyncClient")
|
@patch("haiku.rag.providers.docling_serve.httpx.AsyncClient")
|
||||||
async def test_chunk_auth_error(self, mock_client_class, chunker):
|
async def test_chunk_auth_error(self, mock_client_class, chunker):
|
||||||
"""Test handling of authentication errors."""
|
"""Auth failures surface as httpx.HTTPStatusError(401) so the
|
||||||
|
ingester's pipeline classifier can route them to PermanentError —
|
||||||
|
retrying a bad token is wasted work."""
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
mock_request = Mock()
|
mock_request = Mock()
|
||||||
|
|
@ -482,8 +484,9 @@ class TestDoclingServeChunker:
|
||||||
converter = get_converter(Config)
|
converter = get_converter(Config)
|
||||||
doc = await converter.convert_text("# Test", name="test.md")
|
doc = await converter.convert_text("# Test", name="test.md")
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="Authentication failed"):
|
with pytest.raises(httpx.HTTPStatusError) as exc_info:
|
||||||
await chunker.chunk(doc)
|
await chunker.chunk(doc)
|
||||||
|
assert exc_info.value.response.status_code == 401
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@patch("haiku.rag.providers.docling_serve.httpx.AsyncClient")
|
@patch("haiku.rag.providers.docling_serve.httpx.AsyncClient")
|
||||||
|
|
|
||||||
|
|
@ -1130,7 +1130,7 @@ class TestDoclingServeConverter:
|
||||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
mock_client_class.return_value = mock_client
|
mock_client_class.return_value = mock_client
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="Could not connect to docling-serve"):
|
with pytest.raises(httpx.ConnectError):
|
||||||
await converter.convert_text("# Test")
|
await converter.convert_text("# Test")
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -1143,12 +1143,14 @@ class TestDoclingServeConverter:
|
||||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
mock_client_class.return_value = mock_client
|
mock_client_class.return_value = mock_client
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="timed out"):
|
with pytest.raises(httpx.TimeoutException):
|
||||||
await converter.convert_text("# Test")
|
await converter.convert_text("# Test")
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_convert_text_auth_error(self, converter):
|
async def test_convert_text_auth_error(self, converter):
|
||||||
"""Test handling of authentication errors."""
|
"""Auth failures surface as httpx.HTTPStatusError(401) so the
|
||||||
|
ingester's pipeline classifier can route them to PermanentError —
|
||||||
|
retrying a bad token is wasted work."""
|
||||||
mock_response = Mock()
|
mock_response = Mock()
|
||||||
mock_response.status_code = 401
|
mock_response.status_code = 401
|
||||||
|
|
||||||
|
|
@ -1163,8 +1165,9 @@ class TestDoclingServeConverter:
|
||||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
mock_client_class.return_value = mock_client
|
mock_client_class.return_value = mock_client
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="Authentication failed"):
|
with pytest.raises(httpx.HTTPStatusError) as exc_info:
|
||||||
await converter.convert_text("# Test")
|
await converter.convert_text("# Test")
|
||||||
|
assert exc_info.value.response.status_code == 401
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_convert_file_pdf(self, converter):
|
async def test_convert_file_pdf(self, converter):
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue