Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 26 additions & 14 deletions docling/service_client/_async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
53 changes: 41 additions & 12 deletions docling/service_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down Expand Up @@ -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,
Expand Down
34 changes: 34 additions & 0 deletions tests/test_service_client_sdk_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -1814,6 +1814,40 @@ def fake_submit_convert_task(
]


def test_submit_auto_reports_presigned_rejection_when_inbody_is_also_rejected() -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Test covers only the sync path

The PR changes three call sites: the sync _submit_conversion_job_with_auto_target, AsyncDoclingServiceClient.submit, and process_one in submit_and_retrieve_each. The new test covers only the sync path. The two async copies of the same try/except block have no test. A single async test for submit that uses the same fake would catch a later divergence between the copies.

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,
Expand Down
Loading