Skip to content

Commit 601b473

Browse files
committed
fix(docling_serve): address async job review feedback
1 parent b04476f commit 601b473

3 files changed

Lines changed: 220 additions & 17 deletions

File tree

integrations/docling_serve/src/haystack_integrations/components/converters/docling_serve/__init__.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,14 @@
66
ConversionMode,
77
DoclingServeConversionError,
88
DoclingServeConverter,
9+
DoclingServeTimeoutError,
910
ExportType,
1011
)
1112

12-
__all__ = ["ConversionMode", "DoclingServeConversionError", "DoclingServeConverter", "ExportType"]
13+
__all__ = [
14+
"ConversionMode",
15+
"DoclingServeConversionError",
16+
"DoclingServeConverter",
17+
"DoclingServeTimeoutError",
18+
"ExportType",
19+
]

integrations/docling_serve/src/haystack_integrations/components/converters/docling_serve/converter.py

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ class DoclingServeConversionError(Exception):
5757
"""Raised when DoclingServe reports an async task or conversion failure."""
5858

5959

60+
class DoclingServeTimeoutError(DoclingServeConversionError):
61+
"""Raised when a DoclingServe async task exceeds job_timeout."""
62+
63+
6064
def _is_url(source: str) -> bool:
6165
parsed = urlparse(source)
6266
return parsed.scheme in ("http", "https")
@@ -137,23 +141,25 @@ def __init__(
137141
Conversion mode. `sync` uses DoclingServe's synchronous endpoints. `async` submits
138142
conversion jobs to DoclingServe's async endpoints and polls until completion.
139143
:param poll_interval:
140-
Maximum server-side long-poll wait in seconds when `mode="async"`.
144+
Controls both the server-side long-poll wait (?wait= parameter) and the maximum local sleep between polls.
145+
A higher value reduces round-trips; a lower value increases polling frequency.
141146
:param job_timeout:
142147
Maximum time in seconds to wait for each async conversion job.
143148
"""
144-
if poll_interval <= 0:
145-
msg = "poll_interval must be greater than 0."
146-
raise ValueError(msg)
147-
if job_timeout <= 0:
148-
msg = "job_timeout must be greater than 0."
149-
raise ValueError(msg)
149+
self.mode = ConversionMode(mode)
150+
if self.mode == ConversionMode.ASYNC:
151+
if poll_interval <= 0:
152+
msg = "poll_interval must be greater than 0."
153+
raise ValueError(msg)
154+
if job_timeout <= 0:
155+
msg = "job_timeout must be greater than 0."
156+
raise ValueError(msg)
150157

151158
self.base_url = base_url.rstrip("/")
152159
self.export_type = ExportType(export_type)
153160
self.convert_options = dict(convert_options) if convert_options else {}
154161
self.timeout = timeout
155162
self.api_key = api_key
156-
self.mode = ConversionMode(mode)
157163
self.poll_interval = poll_interval
158164
self.job_timeout = job_timeout
159165

@@ -262,7 +268,9 @@ def _post_file(self, client: httpx.Client, source: str | Path | ByteStream) -> d
262268
headers=self._headers(),
263269
)
264270
response.raise_for_status()
265-
return response.json()
271+
data = response.json()
272+
self._raise_for_failed_conversion(data)
273+
return data
266274

267275
def _submit_file_job(self, client: httpx.Client, source: str | Path | ByteStream) -> str:
268276
filename = _resolve_filename(source)
@@ -293,7 +301,9 @@ def _post_url(self, client: httpx.Client, url: str) -> dict[str, Any]:
293301
headers=self._headers(),
294302
)
295303
response.raise_for_status()
296-
return response.json()
304+
data = response.json()
305+
self._raise_for_failed_conversion(data)
306+
return data
297307

298308
def _submit_url_job(self, client: httpx.Client, url: str) -> str:
299309
response = client.post(
@@ -319,7 +329,7 @@ def _wait_for_job(self, client: httpx.Client, task_id: str) -> None:
319329
remaining = deadline - time.monotonic()
320330
if remaining <= 0:
321331
msg = f"Timed out waiting for DoclingServe task {task_id} after {self.job_timeout:.2f}s."
322-
raise DoclingServeConversionError(msg)
332+
raise DoclingServeTimeoutError(msg)
323333

324334
wait = min(self.poll_interval, remaining)
325335
poll_started = time.monotonic()
@@ -369,7 +379,9 @@ async def _post_file_async(self, client: httpx.AsyncClient, source: str | Path |
369379
headers=self._headers(),
370380
)
371381
response.raise_for_status()
372-
return response.json()
382+
data = response.json()
383+
self._raise_for_failed_conversion(data)
384+
return data
373385

374386
async def _submit_file_job_async(self, client: httpx.AsyncClient, source: str | Path | ByteStream) -> str:
375387
filename = _resolve_filename(source)
@@ -400,7 +412,9 @@ async def _post_url_async(self, client: httpx.AsyncClient, url: str) -> dict[str
400412
headers=self._headers(),
401413
)
402414
response.raise_for_status()
403-
return response.json()
415+
data = response.json()
416+
self._raise_for_failed_conversion(data)
417+
return data
404418

405419
async def _submit_url_job_async(self, client: httpx.AsyncClient, url: str) -> str:
406420
response = await client.post(
@@ -426,7 +440,7 @@ async def _wait_for_job_async(self, client: httpx.AsyncClient, task_id: str) ->
426440
remaining = deadline - time.monotonic()
427441
if remaining <= 0:
428442
msg = f"Timed out waiting for DoclingServe task {task_id} after {self.job_timeout:.2f}s."
429-
raise DoclingServeConversionError(msg)
443+
raise DoclingServeTimeoutError(msg)
430444

431445
wait = min(self.poll_interval, remaining)
432446
poll_started = time.monotonic()
@@ -517,6 +531,12 @@ def run(
517531
source=source,
518532
error=e,
519533
)
534+
except DoclingServeTimeoutError as e:
535+
logger.warning(
536+
"Timed out waiting for DoclingServe conversion for {source}. Skipping it. Error: {error}",
537+
source=source,
538+
error=e,
539+
)
520540
except DoclingServeConversionError as e:
521541
logger.warning(
522542
"DoclingServe conversion failed for {source}. Skipping it. Error: {error}",
@@ -585,6 +605,12 @@ async def run_async(
585605
source=source,
586606
error=e,
587607
)
608+
except DoclingServeTimeoutError as e:
609+
logger.warning(
610+
"Timed out waiting for DoclingServe conversion for {source}. Skipping it. Error: {error}",
611+
source=source,
612+
error=e,
613+
)
588614
except DoclingServeConversionError as e:
589615
logger.warning(
590616
"DoclingServe conversion failed for {source}. Skipping it. Error: {error}",

integrations/docling_serve/tests/test_converter.py

Lines changed: 172 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ def _mock_status_response(
3737
return httpx.Response(200, json=json_data, request=httpx.Request("GET", "http://test"))
3838

3939

40+
def _mock_failed_result_response(error_message: str = "conversion failed", status: str = "failure") -> httpx.Response:
41+
json_data = {"status": status, "errors": [{"error_message": error_message}]}
42+
return httpx.Response(200, json=json_data, request=httpx.Request("GET", "http://test"))
43+
44+
4045
class TestDoclingServeConverterInit:
4146
def test_defaults(self, monkeypatch):
4247
monkeypatch.delenv("DOCLING_SERVE_API_KEY", raising=False)
@@ -88,10 +93,16 @@ def test_api_key_none_overrides_env(self, monkeypatch):
8893

8994
def test_invalid_async_job_settings(self):
9095
with pytest.raises(ValueError, match="poll_interval"):
91-
DoclingServeConverter(poll_interval=0)
96+
DoclingServeConverter(mode=ConversionMode.ASYNC, poll_interval=0)
9297

9398
with pytest.raises(ValueError, match="job_timeout"):
94-
DoclingServeConverter(job_timeout=0)
99+
DoclingServeConverter(mode=ConversionMode.ASYNC, job_timeout=0)
100+
101+
def test_sync_mode_allows_unused_async_job_settings(self):
102+
converter = DoclingServeConverter(poll_interval=0, job_timeout=0)
103+
assert converter.mode == ConversionMode.SYNC
104+
assert converter.poll_interval == 0
105+
assert converter.job_timeout == 0
95106

96107

97108
class TestDoclingServeConverterSerialization:
@@ -290,6 +301,30 @@ def test_run_skips_when_no_content(self, caplog):
290301
assert result["documents"] == []
291302
assert "No content returned" in caplog.text
292303

304+
def test_run_skips_failed_sync_url_response_details(self, caplog):
305+
converter = DoclingServeConverter(api_key=None)
306+
mock_resp = _mock_failed_result_response("url conversion failed")
307+
308+
with patch("httpx.Client.post", return_value=mock_resp):
309+
with caplog.at_level(logging.WARNING):
310+
result = converter.run(sources=["https://example.com/doc.pdf"])
311+
312+
assert result["documents"] == []
313+
assert "url conversion failed" in caplog.text
314+
315+
def test_run_skips_failed_sync_file_response_details(self, tmp_path, caplog):
316+
pdf = tmp_path / "test.pdf"
317+
pdf.write_bytes(b"%PDF-test")
318+
converter = DoclingServeConverter(api_key=None)
319+
mock_resp = _mock_failed_result_response("file conversion failed")
320+
321+
with patch("httpx.Client.post", return_value=mock_resp):
322+
with caplog.at_level(logging.WARNING):
323+
result = converter.run(sources=[pdf])
324+
325+
assert result["documents"] == []
326+
assert "file conversion failed" in caplog.text
327+
293328
def test_run_text_export(self):
294329
converter = DoclingServeConverter(export_type=ExportType.TEXT, api_key=None)
295330
mock_resp = _mock_httpx_response("plain text content", ExportType.TEXT)
@@ -400,6 +435,50 @@ def test_run_async_mode_skips_failed_job(self, caplog):
400435
assert result["documents"] == []
401436
assert "conversion failed" in caplog.text
402437

438+
def test_run_async_mode_skips_failed_result(self, caplog):
439+
converter = DoclingServeConverter(api_key=None, mode=ConversionMode.ASYNC)
440+
441+
with (
442+
patch("httpx.Client.post", return_value=_mock_task_response()),
443+
patch("httpx.Client.get", side_effect=[_mock_status_response(), _mock_failed_result_response("bad file")]),
444+
):
445+
with caplog.at_level(logging.WARNING):
446+
result = converter.run(sources=["https://example.com/doc.pdf"])
447+
448+
assert result["documents"] == []
449+
assert "bad file" in caplog.text
450+
451+
def test_run_async_mode_skips_skipped_result(self, caplog):
452+
converter = DoclingServeConverter(api_key=None, mode=ConversionMode.ASYNC)
453+
454+
with (
455+
patch("httpx.Client.post", return_value=_mock_task_response()),
456+
patch(
457+
"httpx.Client.get",
458+
side_effect=[_mock_status_response(), _mock_failed_result_response("skipped file", status="skipped")],
459+
),
460+
):
461+
with caplog.at_level(logging.WARNING):
462+
result = converter.run(sources=["https://example.com/doc.pdf"])
463+
464+
assert result["documents"] == []
465+
assert "skipped file" in caplog.text
466+
467+
def test_run_async_mode_skips_timed_out_job(self, caplog):
468+
converter = DoclingServeConverter(
469+
api_key=None, mode=ConversionMode.ASYNC, poll_interval=0.001, job_timeout=0.001
470+
)
471+
472+
with (
473+
patch("httpx.Client.post", return_value=_mock_task_response()),
474+
patch("httpx.Client.get", return_value=_mock_status_response(status="pending")),
475+
):
476+
with caplog.at_level(logging.WARNING):
477+
result = converter.run(sources=["https://example.com/doc.pdf"])
478+
479+
assert result["documents"] == []
480+
assert "Timed out" in caplog.text
481+
403482

404483
class TestDoclingServeConverterFilename:
405484
def test_bytestream_filename_from_file_name(self):
@@ -463,6 +542,37 @@ async def test_run_async_url_source(self):
463542
assert len(result["documents"]) == 1
464543
assert result["documents"][0].content == "# Async content"
465544

545+
@pytest.mark.asyncio
546+
async def test_run_async_file_source(self, tmp_path):
547+
pdf = tmp_path / "test.pdf"
548+
pdf.write_bytes(b"%PDF-test")
549+
converter = DoclingServeConverter(api_key=None)
550+
mock_resp = httpx.Response(
551+
200,
552+
json={"document": {"md_content": "# Async file content"}, "status": "success"},
553+
request=httpx.Request("POST", "http://test"),
554+
)
555+
556+
with patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=mock_resp) as mock_post:
557+
result = await converter.run_async(sources=[pdf])
558+
559+
post_url = mock_post.call_args[0][0]
560+
assert post_url.endswith("/v1/convert/file")
561+
assert result["documents"][0].content == "# Async file content"
562+
563+
@pytest.mark.asyncio
564+
async def test_run_async_skips_failed_sync_response_details(self, caplog):
565+
converter = DoclingServeConverter(api_key=None)
566+
567+
with patch(
568+
"httpx.AsyncClient.post", new_callable=AsyncMock, return_value=_mock_failed_result_response("async failure")
569+
):
570+
with caplog.at_level(logging.WARNING):
571+
result = await converter.run_async(sources=["https://example.com/doc.pdf"])
572+
573+
assert result["documents"] == []
574+
assert "async failure" in caplog.text
575+
466576
@pytest.mark.asyncio
467577
async def test_run_async_skips_on_status_error(self, caplog):
468578
converter = DoclingServeConverter(api_key=None)
@@ -534,6 +644,66 @@ async def test_run_async_async_mode_uses_job_endpoints(self):
534644
assert status_call.kwargs["params"] == {"wait": 1.5}
535645
assert result_call.args[0].endswith("/v1/result/task-1")
536646

647+
@pytest.mark.asyncio
648+
async def test_run_async_async_mode_uses_job_endpoints_for_file_source(self, tmp_path):
649+
pdf = tmp_path / "test.pdf"
650+
pdf.write_bytes(b"%PDF-test")
651+
converter = DoclingServeConverter(api_key=None, mode=ConversionMode.ASYNC)
652+
653+
with (
654+
patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=_mock_task_response()) as mock_post,
655+
patch(
656+
"httpx.AsyncClient.get",
657+
new_callable=AsyncMock,
658+
side_effect=[_mock_status_response(), _mock_httpx_response("async file content")],
659+
),
660+
):
661+
result = await converter.run_async(sources=[pdf])
662+
663+
post_url = mock_post.call_args[0][0]
664+
data = mock_post.call_args[1]["data"]
665+
assert post_url.endswith("/v1/convert/file/async")
666+
assert data["target_type"] == "inbody"
667+
assert result["documents"][0].content == "async file content"
668+
669+
@pytest.mark.asyncio
670+
async def test_run_async_async_mode_skips_failed_result(self, caplog):
671+
converter = DoclingServeConverter(api_key=None, mode=ConversionMode.ASYNC)
672+
673+
with (
674+
patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=_mock_task_response()),
675+
patch(
676+
"httpx.AsyncClient.get",
677+
new_callable=AsyncMock,
678+
side_effect=[_mock_status_response(), _mock_failed_result_response("bad async result")],
679+
),
680+
):
681+
with caplog.at_level(logging.WARNING):
682+
result = await converter.run_async(sources=["https://example.com/doc.pdf"])
683+
684+
assert result["documents"] == []
685+
assert "bad async result" in caplog.text
686+
687+
@pytest.mark.asyncio
688+
async def test_run_async_async_mode_skips_timed_out_job(self, caplog):
689+
converter = DoclingServeConverter(
690+
api_key=None, mode=ConversionMode.ASYNC, poll_interval=0.001, job_timeout=0.001
691+
)
692+
693+
with (
694+
patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=_mock_task_response()),
695+
patch(
696+
"httpx.AsyncClient.get",
697+
new_callable=AsyncMock,
698+
return_value=_mock_status_response(status="pending"),
699+
),
700+
):
701+
with caplog.at_level(logging.WARNING):
702+
result = await converter.run_async(sources=["https://example.com/doc.pdf"])
703+
704+
assert result["documents"] == []
705+
assert "Timed out" in caplog.text
706+
537707

538708
class TestDoclingServeConverterIntegration:
539709
@pytest.mark.integration

0 commit comments

Comments
 (0)