haiku.rag/haiku_rag_slim/haiku/rag/providers/docling_serve.py
Yiorgis Gozadinos 15868762b0
Wire providers.docling_serve.timeout through to the client
The setting was documented but did not exist on DoclingServeConfig, so it
was silently dropped, and DoclingServeClient.from_config never forwarded
the timeout parameter it already accepted. The per-request timeout was
therefore pinned at the constructor default of 300s with no way to change
it. Add the field, forward it, and reject a non-positive value.

Also parametrize over the checked-in *.yaml.example files and validate
each through AppConfig, so an example that no longer loads fails a test
rather than a user's first run.
2026-08-19 13:30:45 +03:00

299 lines
12 KiB
Python

"""Shared client for docling-serve async API."""
import asyncio
import itertools
import logging
import time
from collections.abc import Awaitable, Callable
from typing import Any, TypeVar
import httpx
from haiku.rag.circuit_breaker import CircuitBreaker
from haiku.rag.config import CircuitBreakerConfig, DoclingServeConfig
from haiku.rag.telemetry import logfire
logger = logging.getLogger(__name__)
_T = TypeVar("_T")
# Statuses that mean "try again elsewhere" rather than "this request is bad".
_RETRYABLE_STATUS = frozenset({408, 429})
def _is_retryable(exc: BaseException) -> bool:
"""Whether a failure reflects an unhealthy docling-serve instance.
True for transport errors (a crashed / unresponsive instance) and
server-side 5xx / 408 / 429. Such failures are retried on another instance
and counted against the instance's circuit breaker. Other 4xx and the
task-level ValueError from `_submit_and_wait` (a bad document, not the
instance's fault) won't succeed on a retry, so they propagate untouched.
"""
if isinstance(exc, httpx.HTTPStatusError):
status = exc.response.status_code
return status >= 500 or status in _RETRYABLE_STATUS
return isinstance(exc, httpx.TransportError)
# Process-global registries over docling-serve instances. Clients are built
# per-job by `get_converter` / `get_chunker`, so this shared state lives OUTSIDE
# the instance to persist across jobs: the round-robin cursor (keyed by the
# sorted URL set) advances across jobs, and the per-URL breakers keep a crashed
# instance skipped across jobs until its cooldown elapses. The first client to
# touch a URL fixes that breaker's config; in practice all clients share one.
_instance_rotators: dict[tuple[str, ...], "itertools.cycle[str]"] = {}
_instance_breakers: dict[str, CircuitBreaker] = {}
class DoclingServeClient:
"""Client for docling-serve async workflow.
Handles the submit → poll → fetch pattern used by both conversion and
chunking. Accepts a list of base URLs and round-robins jobs across them via
a process-wide rotator keyed by the URL set. Each job's submit/poll/result
trio stays on the same instance — task IDs are instance-local, so picking a
different URL mid-job would 404.
When an instance crashes or returns 5xx, the request fails over to another
instance (up to `max_attempts`) and the failure trips that instance's
circuit breaker, so subsequent `_pick_url` calls skip it while it recovers.
"""
def __init__(
self,
base_urls: str | list[str],
api_key: str | None = None,
timeout: float = 300,
transport: httpx.AsyncBaseTransport | None = None,
circuit_breaker: CircuitBreakerConfig | None = None,
max_attempts: int = 3,
retry_base_delay: float = 0.5,
retry_max_delay: float = 8.0,
now_fn: Callable[[], float] = time.monotonic,
):
urls = [base_urls] if isinstance(base_urls, str) else list(base_urls)
if not urls:
raise ValueError("DoclingServeClient requires at least one base_url")
self.base_urls: list[str] = [u.rstrip("/") for u in urls]
self.api_key = api_key
self.timeout = timeout
# transport is for testing — production callers leave it None.
self._transport = transport
self._breaker_config = circuit_breaker or CircuitBreakerConfig()
self._max_attempts = max(1, max_attempts)
self._retry_base_delay = retry_base_delay
self._retry_max_delay = retry_max_delay
self._now = now_fn
# setdefault is atomic under the GIL — concurrent constructors with
# the same instance set will end up sharing one rotator.
key = tuple(sorted(self.base_urls))
self._instance_rotator = _instance_rotators.setdefault(
key, itertools.cycle(self.base_urls)
)
@classmethod
def from_config(cls, config: DoclingServeConfig) -> "DoclingServeClient":
return cls(
base_urls=config.base_urls,
api_key=config.api_key,
timeout=config.timeout,
circuit_breaker=config.circuit_breaker,
max_attempts=config.max_attempts,
)
def _httpx_client(self) -> httpx.AsyncClient:
return httpx.AsyncClient(timeout=self.timeout, transport=self._transport)
@property
def base_url(self) -> str:
"""First-URL view, mostly for log messages. Don't use for dispatch —
callers should let `_pick_url` round-robin per request."""
return self.base_urls[0]
def _breaker_for(self, url: str) -> CircuitBreaker:
breaker = _instance_breakers.get(url)
if breaker is None:
breaker = CircuitBreaker(self._breaker_config, now_fn=self._now)
_instance_breakers[url] = breaker
return breaker
def _pick_url(self, exclude: frozenset[str] = frozenset()) -> str:
"""Next instance in the round-robin, skipping instances that already
failed this request (`exclude`) or whose circuit breaker is open.
When no healthy, not-excluded instance exists, probe one anyway (the
loop consumed a full rotation, so one more step rotates across calls
rather than pinning one node) — a single-instance or fully-down fleet
still gets an attempt instead of failing with nothing to pick.
"""
for _ in range(len(self.base_urls)):
url = next(self._instance_rotator)
if url in exclude:
continue
if not self._breaker_for(url).is_open:
return url
return next(self._instance_rotator)
def _retry_delay(self, attempt_no: int) -> float:
"""Capped exponential backoff between retry attempts."""
return min(self._retry_base_delay * (2**attempt_no), self._retry_max_delay)
async def _run_with_retry(
self,
attempt: Callable[[httpx.AsyncClient, str], Awaitable[_T]],
name: str,
) -> _T:
"""Run `attempt(client, base_url)` with bounded retry + failover.
Each attempt runs the full submit → poll → fetch trio against one
instance (task IDs are instance-local, so a trio can't be split across
instances). A retryable failure counts against that instance's breaker
and fails over to another instance; a successful attempt closes the
breaker. Non-retryable errors propagate immediately.
"""
tried: set[str] = set()
last_exc: BaseException | None = None
for attempt_no in range(self._max_attempts):
base_url = self._pick_url(exclude=frozenset(tried))
breaker = self._breaker_for(base_url)
try:
with logfire.span(
"docling_serve.request",
name=name,
url=base_url,
attempt=attempt_no,
):
async with self._httpx_client() as client:
result = await attempt(client, base_url)
except Exception as exc:
if not _is_retryable(exc):
raise
breaker.record_failure()
tried.add(base_url)
last_exc = exc
if attempt_no + 1 >= self._max_attempts:
raise
logger.warning(
"docling-serve request for %s failed on %s "
"(attempt %d/%d), retrying on another instance: %s",
name,
base_url,
attempt_no + 1,
self._max_attempts,
exc,
)
await asyncio.sleep(self._retry_delay(attempt_no))
else:
breaker.record_success()
return result
raise last_exc or RuntimeError( # pragma: no cover
"retry loop exited without a result"
)
def _get_headers(self) -> dict[str, str]:
"""Get headers for API requests."""
headers: dict[str, str] = {}
if self.api_key:
headers["X-Api-Key"] = self.api_key
return headers
async def _submit_and_wait(
self,
client: httpx.AsyncClient,
base_url: str,
endpoint: str,
files: dict[str, Any],
data: dict[str, Any],
headers: dict[str, str],
name: str,
) -> str:
"""Submit a task and poll until success. Returns the task_id.
Shared by submit_and_poll (JSON results) and submit_and_poll_zip
(binary zip results) — only the result-fetching step differs.
"""
submit_url = f"{base_url}{endpoint}"
response = await client.post(
submit_url,
files=files,
data=data,
headers=headers,
)
response.raise_for_status()
submit_result = response.json()
task_id = submit_result.get("task_id")
if not task_id:
raise ValueError("docling-serve did not return a task_id")
poll_url = f"{base_url}/v1/status/poll/{task_id}"
while True:
poll_response = await client.get(poll_url, headers=headers)
poll_response.raise_for_status()
poll_result = poll_response.json()
status = poll_result.get("task_status")
if status == "success":
return task_id
elif status in ("failure", "error"):
raise ValueError(f"docling-serve task failed for {name}: {poll_result}")
await asyncio.sleep(1)
async def submit_and_poll(
self,
endpoint: str,
files: dict[str, Any],
data: dict[str, Any],
name: str = "document",
) -> dict[str, Any]:
"""Submit a task and poll until completion; fetch result as JSON.
Retries on a different instance when an instance crashes or returns
5xx/overload (see `_run_with_retry`). Non-retryable httpx exceptions
(4xx other than 408/429) and the ValueError raised by `_submit_and_wait`
(task failure / missing task_id) propagate so the ingester's pipeline
classifier can route them (4xx → PermanentError, ValueError →
TransientError for a whole-document retry).
"""
headers = self._get_headers()
async def _attempt(client: httpx.AsyncClient, base_url: str) -> dict[str, Any]:
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_response.raise_for_status()
return result_response.json()
return await self._run_with_retry(_attempt, name)
async def submit_and_poll_zip(
self,
endpoint: str,
files: dict[str, Any],
data: dict[str, Any],
name: str = "document",
) -> bytes:
"""Submit a task and poll until completion; fetch result as raw bytes.
Used when the caller requested ``target_type=zip`` (e.g. to retrieve
picture image bytes that docling-serve only emits as referenced files
bundled into a zip archive). The submit/poll flow is identical to
``submit_and_poll`` (including the retry + failover); only the
result-fetching step differs.
"""
headers = self._get_headers()
async def _attempt(client: httpx.AsyncClient, base_url: str) -> bytes:
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_response.raise_for_status()
return result_response.content
return await self._run_with_retry(_attempt, name)