From fbf2c392044c475c40669a8116b71556107db16e Mon Sep 17 00:00:00 2001 From: Michele Dolfi Date: Sun, 25 Oct 2026 07:08:57 +0100 Subject: [PATCH 1/2] fix(service-client): keep the presigned rejection when the InBody fallback fails When a server rejects the presigned_url target and then also rejects the InBody fallback, the client raised only the InBody error. This hid the actionable cause, for example that artifact storage is not enabled. The client now reports both rejections in the error detail. Signed-off-by: Michele Dolfi --- docling/service_client/_async_client.py | 40 ++++++++++++------- docling/service_client/client.py | 51 +++++++++++++++++++------ tests/test_service_client_sdk_unit.py | 34 +++++++++++++++++ 3 files changed, 99 insertions(+), 26 deletions(-) diff --git a/docling/service_client/_async_client.py b/docling/service_client/_async_client.py index 0c9a3eab61..372b2ace19 100644 --- a/docling/service_client/_async_client.py +++ b/docling/service_client/_async_client.py @@ -264,13 +264,19 @@ async def submit( output_formats=output_formats, target=effective_target, ) - initial_status = await self._submit_convert_task( - source=source, - options=submit_options, - target=effective_target, - async_client=self._async_client, - request_headers=headers, - ) + try: + initial_status = await self._submit_convert_task( + source=source, + options=submit_options, + target=effective_target, + async_client=self._async_client, + request_headers=headers, + ) + except ServiceError as fallback_exc: + combined = self._inbody_fallback_error(exc, fallback_exc) + if combined is None: + raise + raise combined from fallback_exc else: effective_target = target submit_options = self._options_for_output_formats( @@ -493,13 +499,19 @@ async def process_one( output_formats=None, target=effective_target, ) - initial_status = await self._submit_convert_task( - source=item.source, - options=submit_options, - target=effective_target, - async_client=async_client, - request_headers=item.headers, - ) + try: + initial_status = await self._submit_convert_task( + source=item.source, + options=submit_options, + target=effective_target, + async_client=async_client, + request_headers=item.headers, + ) + except ServiceError as fallback_exc: + combined = self._inbody_fallback_error(exc, fallback_exc) + if combined is None: + raise + raise combined from fallback_exc terminal_status = ( await self._wait_for_terminal_status_for_submit_and_retrieve_many( diff --git a/docling/service_client/client.py b/docling/service_client/client.py index e8aaa12990..be51e53522 100644 --- a/docling/service_client/client.py +++ b/docling/service_client/client.py @@ -852,6 +852,26 @@ def _should_fallback_from_presigned_target(self, exc: ServiceError) -> bool: ) ) + @staticmethod + def _inbody_fallback_error( + presigned_exc: ServiceError, fallback_exc: ServiceError + ) -> ServiceError | None: + """Combine both rejections when the server refuses the InBody fallback too. + + The presigned rejection is usually the actionable cause (for example, + artifact storage is not enabled), so the fallback error must not hide it. + """ + if fallback_exc.status_code not in {400, 422}: + return None + return ServiceError( + fallback_exc.message, + status_code=fallback_exc.status_code, + detail=( + f"{fallback_exc.detail} The client tried the InBody target because " + f"the server rejected the presigned_url target: {presigned_exc.detail}" + ), + ) + def _url(self, path: str) -> str: if path.startswith("/"): return f"{self._base_url}{path}" @@ -1480,19 +1500,26 @@ def _submit_conversion_job_with_auto_target( except ServiceError as exc: if not self._should_fallback_from_presigned_target(exc): raise - return self._submit_conversion_job( - source=source, - options=self._options_for_output_formats( - options, - output_formats=None, + presigned_exc = exc + try: + return self._submit_conversion_job( + source=source, + options=self._options_for_output_formats( + options, + output_formats=None, + target=InBodyTarget(), + ), + limits=limits, target=InBodyTarget(), - ), - limits=limits, - target=InBodyTarget(), - descriptor=descriptor, - request_headers=request_headers, - materialize_presigned=materialize_presigned, - ) + descriptor=descriptor, + request_headers=request_headers, + materialize_presigned=materialize_presigned, + ) + except ServiceError as fallback_exc: + combined = self._inbody_fallback_error(presigned_exc, fallback_exc) + if combined is None: + raise + raise combined from fallback_exc def _make_convert_fetch_result_handler( self, diff --git a/tests/test_service_client_sdk_unit.py b/tests/test_service_client_sdk_unit.py index 04959ee6f3..4ed6a159a6 100644 --- a/tests/test_service_client_sdk_unit.py +++ b/tests/test_service_client_sdk_unit.py @@ -1814,6 +1814,40 @@ def fake_submit_convert_task( ] +def test_submit_auto_reports_presigned_rejection_when_inbody_is_also_rejected() -> None: + def fake_submit_convert_task( + self, + source, + options, + target, + request_headers=None, + ): + if isinstance(target, PresignedUrlTarget): + raise ServiceError( + "Task submission failed.", + status_code=422, + detail=( + "Presigned URL target requires artifact storage to be configured " + "and enabled on the server." + ), + ) + raise ServiceError( + "Task submission failed.", + status_code=422, + detail="target kind 'inbody' is not allowed.", + ) + + with DoclingServiceClient(url=TEST_BASE_URL) as client: + client._submit_convert_task = MethodType(fake_submit_convert_task, client) + with pytest.raises(ServiceError) as exc_info: + client.submit(source="https://example.org/sample.pdf") + + assert exc_info.value.status_code == 422 + assert exc_info.value.detail is not None + assert "target kind 'inbody' is not allowed." in exc_info.value.detail + assert "requires artifact storage" in exc_info.value.detail + + def test_submit_and_retrieve_each_auto_target_returns_presigned_when_supported( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, From bf7d0f88346ba88813121706d788ef070d78c278 Mon Sep 17 00:00:00 2001 From: Christoph Auer Date: Mon, 26 Oct 2026 13:46:33 +0100 Subject: [PATCH 2/2] fix(service-client): omit an empty InBody detail from the combined error When the InBody rejection has no string detail (for example, a FastAPI validation error with a list detail), the combined message started with the text "None". Add the InBody detail only when it is set. Signed-off-by: Christoph Auer --- docling/service_client/client.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docling/service_client/client.py b/docling/service_client/client.py index be51e53522..97c98f77cc 100644 --- a/docling/service_client/client.py +++ b/docling/service_client/client.py @@ -863,11 +863,13 @@ def _inbody_fallback_error( """ if fallback_exc.status_code not in {400, 422}: return None + # A FastAPI validation error has a list detail, which the client maps to None. + prefix = f"{fallback_exc.detail} " if fallback_exc.detail else "" return ServiceError( fallback_exc.message, status_code=fallback_exc.status_code, detail=( - f"{fallback_exc.detail} The client tried the InBody target because " + f"{prefix}The client tried the InBody target because " f"the server rejected the presigned_url target: {presigned_exc.detail}" ), )