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..97c98f77cc 100644 --- a/docling/service_client/client.py +++ b/docling/service_client/client.py @@ -852,6 +852,28 @@ 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 + # 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"{prefix}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 +1502,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,