change breakers to be per-instance
This commit is contained in:
parent
73beae5a4c
commit
98c8d0b4f9
2 changed files with 272 additions and 19 deletions
|
|
@ -2,10 +2,71 @@
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import itertools
|
import itertools
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# HTTP statuses that count against an instance's health: 408/429 are transient
|
||||||
|
# overload, 5xx is a crashed/restarting worker (the OOM-leak failure mode).
|
||||||
|
_UNHEALTHY_STATUS = frozenset({408, 429})
|
||||||
|
|
||||||
|
|
||||||
|
def _is_instance_failure(exc: BaseException) -> bool:
|
||||||
|
"""Whether a failure reflects an unhealthy docling-serve instance and should
|
||||||
|
count against its circuit breaker: transport errors (connection reset /
|
||||||
|
timeout — a crashed or unresponsive instance) and 5xx/overload. Other 4xx
|
||||||
|
are the caller's fault and the task-level ``ValueError`` from
|
||||||
|
``_submit_and_wait`` is a document problem — neither reflects instance
|
||||||
|
health, so they don't trip the breaker."""
|
||||||
|
if isinstance(exc, httpx.HTTPStatusError):
|
||||||
|
status = exc.response.status_code
|
||||||
|
return status in _UNHEALTHY_STATUS or status >= 500
|
||||||
|
return isinstance(exc, httpx.TransportError)
|
||||||
|
|
||||||
|
|
||||||
|
class _InstanceBreaker:
|
||||||
|
"""Per-instance circuit breaker (closed / open with cooldown).
|
||||||
|
|
||||||
|
Self-contained so the provider layer doesn't depend on the ingester
|
||||||
|
package. ``is_open`` auto-probes after the cooldown elapses: a single
|
||||||
|
request is allowed through, and its success closes the breaker while another
|
||||||
|
failure re-opens it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
failure_threshold: int,
|
||||||
|
cooldown_s: float,
|
||||||
|
now_fn: Callable[[], float],
|
||||||
|
):
|
||||||
|
self._threshold = failure_threshold
|
||||||
|
self._cooldown_s = cooldown_s
|
||||||
|
self._now = now_fn
|
||||||
|
self._consecutive_failures = 0
|
||||||
|
self._opened_at: float | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_open(self) -> bool:
|
||||||
|
if self._opened_at is None:
|
||||||
|
return False
|
||||||
|
# Cooldown elapsed → let the next request probe the instance.
|
||||||
|
return self._now() - self._opened_at < self._cooldown_s
|
||||||
|
|
||||||
|
def record_success(self) -> None:
|
||||||
|
self._consecutive_failures = 0
|
||||||
|
self._opened_at = None
|
||||||
|
|
||||||
|
def record_failure(self) -> None:
|
||||||
|
self._consecutive_failures += 1
|
||||||
|
if self._consecutive_failures >= self._threshold:
|
||||||
|
self._opened_at = self._now()
|
||||||
|
|
||||||
|
|
||||||
# Process-global registry of round-robin rotators over docling-serve
|
# Process-global registry of round-robin rotators over docling-serve
|
||||||
# instances, keyed by the sorted tuple of base URLs. Clients are constructed
|
# instances, keyed by the sorted tuple of base URLs. Clients are constructed
|
||||||
# per-job by `get_converter` / `get_chunker`, so the rotation cursor has to
|
# per-job by `get_converter` / `get_chunker`, so the rotation cursor has to
|
||||||
|
|
@ -14,6 +75,13 @@ import httpx
|
||||||
# different sets get independent rotators.
|
# different sets get independent rotators.
|
||||||
_instance_rotators: dict[tuple[str, ...], "itertools.cycle[str]"] = {}
|
_instance_rotators: dict[tuple[str, ...], "itertools.cycle[str]"] = {}
|
||||||
|
|
||||||
|
# Process-global per-instance breakers, keyed by base URL. Like the rotators,
|
||||||
|
# these must outlive the per-job client so an instance that crashed stays
|
||||||
|
# skipped across subsequent jobs until its cooldown elapses. The first client
|
||||||
|
# to touch a URL fixes that breaker's threshold/cooldown/clock; in practice all
|
||||||
|
# clients share one config (the docling-serve provider config).
|
||||||
|
_instance_breakers: dict[str, "_InstanceBreaker"] = {}
|
||||||
|
|
||||||
|
|
||||||
class DoclingServeClient:
|
class DoclingServeClient:
|
||||||
"""Client for docling-serve async workflow.
|
"""Client for docling-serve async workflow.
|
||||||
|
|
@ -31,6 +99,9 @@ class DoclingServeClient:
|
||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
timeout: float = 300,
|
timeout: float = 300,
|
||||||
transport: httpx.AsyncBaseTransport | None = None,
|
transport: httpx.AsyncBaseTransport | None = None,
|
||||||
|
breaker_failure_threshold: int = 3,
|
||||||
|
breaker_cooldown_s: float = 30.0,
|
||||||
|
now_fn: Callable[[], float] = time.monotonic,
|
||||||
):
|
):
|
||||||
urls = [base_urls] if isinstance(base_urls, str) else list(base_urls)
|
urls = [base_urls] if isinstance(base_urls, str) else list(base_urls)
|
||||||
if not urls:
|
if not urls:
|
||||||
|
|
@ -40,6 +111,9 @@ class DoclingServeClient:
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
# transport is for testing — production callers leave it None.
|
# transport is for testing — production callers leave it None.
|
||||||
self._transport = transport
|
self._transport = transport
|
||||||
|
self._breaker_failure_threshold = breaker_failure_threshold
|
||||||
|
self._breaker_cooldown_s = breaker_cooldown_s
|
||||||
|
self._now = now_fn
|
||||||
# setdefault is atomic under the GIL — concurrent constructors with
|
# setdefault is atomic under the GIL — concurrent constructors with
|
||||||
# the same instance set will end up sharing one rotator.
|
# the same instance set will end up sharing one rotator.
|
||||||
key = tuple(sorted(self.base_urls))
|
key = tuple(sorted(self.base_urls))
|
||||||
|
|
@ -56,8 +130,49 @@ class DoclingServeClient:
|
||||||
callers should let `_pick_url` round-robin per request."""
|
callers should let `_pick_url` round-robin per request."""
|
||||||
return self.base_urls[0]
|
return self.base_urls[0]
|
||||||
|
|
||||||
|
def _breaker_for(self, url: str) -> _InstanceBreaker:
|
||||||
|
breaker = _instance_breakers.get(url)
|
||||||
|
if breaker is None:
|
||||||
|
breaker = _InstanceBreaker(
|
||||||
|
self._breaker_failure_threshold,
|
||||||
|
self._breaker_cooldown_s,
|
||||||
|
self._now,
|
||||||
|
)
|
||||||
|
_instance_breakers[url] = breaker
|
||||||
|
return breaker
|
||||||
|
|
||||||
def _pick_url(self) -> str:
|
def _pick_url(self) -> str:
|
||||||
return next(self._instance_rotator)
|
"""Next instance in the round-robin, skipping any whose circuit breaker
|
||||||
|
is open (an instance that recently crashed / overloaded). Falls back to
|
||||||
|
the first instance seen when every breaker is open — a single-instance
|
||||||
|
fleet, or a fully-down one, still gets a probe rather than no attempt."""
|
||||||
|
fallback: str | None = None
|
||||||
|
for _ in range(len(self.base_urls)):
|
||||||
|
url = next(self._instance_rotator)
|
||||||
|
if fallback is None:
|
||||||
|
fallback = url
|
||||||
|
if not self._breaker_for(url).is_open:
|
||||||
|
return url
|
||||||
|
assert fallback is not None # base_urls is non-empty (checked in __init__)
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
def _record_outcome(self, base_url: str, exc: BaseException) -> None:
|
||||||
|
"""Record a failed request against the instance's breaker, but only when
|
||||||
|
the failure reflects instance health (transport / 5xx). 4xx and
|
||||||
|
task-level errors are left alone — they aren't the instance's fault."""
|
||||||
|
if not _is_instance_failure(exc):
|
||||||
|
return
|
||||||
|
breaker = self._breaker_for(base_url)
|
||||||
|
was_open = breaker.is_open
|
||||||
|
breaker.record_failure()
|
||||||
|
if not was_open and breaker.is_open:
|
||||||
|
logger.warning(
|
||||||
|
"docling-serve instance %s breaker opened after %d consecutive "
|
||||||
|
"failure(s); skipping it for %.0fs",
|
||||||
|
base_url,
|
||||||
|
self._breaker_failure_threshold,
|
||||||
|
self._breaker_cooldown_s,
|
||||||
|
)
|
||||||
|
|
||||||
def _get_headers(self) -> dict[str, str]:
|
def _get_headers(self) -> dict[str, str]:
|
||||||
"""Get headers for API requests."""
|
"""Get headers for API requests."""
|
||||||
|
|
@ -122,18 +237,26 @@ class DoclingServeClient:
|
||||||
etc.) propagate so the ingester's pipeline classifier can route
|
etc.) propagate so the ingester's pipeline classifier can route
|
||||||
4xx → PermanentError and 5xx/network → TransientError. ValueError
|
4xx → PermanentError and 5xx/network → TransientError. ValueError
|
||||||
is raised by `_submit_and_wait` when docling-serve reports a task
|
is raised by `_submit_and_wait` when docling-serve reports a task
|
||||||
failure or returns no task_id.
|
failure or returns no task_id. Instance-health failures (transport /
|
||||||
|
5xx) trip the picked instance's circuit breaker so later requests skip
|
||||||
|
it while it recovers.
|
||||||
"""
|
"""
|
||||||
headers = self._get_headers()
|
headers = self._get_headers()
|
||||||
base_url = self._pick_url()
|
base_url = self._pick_url()
|
||||||
async with self._httpx_client() as client:
|
try:
|
||||||
task_id = await self._submit_and_wait(
|
async with self._httpx_client() as client:
|
||||||
client, base_url, endpoint, files, data, headers, name
|
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_url = f"{base_url}/v1/result/{task_id}"
|
||||||
result_response.raise_for_status()
|
result_response = await client.get(result_url, headers=headers)
|
||||||
return result_response.json()
|
result_response.raise_for_status()
|
||||||
|
result = result_response.json()
|
||||||
|
except Exception as exc:
|
||||||
|
self._record_outcome(base_url, exc)
|
||||||
|
raise
|
||||||
|
self._breaker_for(base_url).record_success()
|
||||||
|
return result
|
||||||
|
|
||||||
async def submit_and_poll_zip(
|
async def submit_and_poll_zip(
|
||||||
self,
|
self,
|
||||||
|
|
@ -147,15 +270,22 @@ class DoclingServeClient:
|
||||||
Used when the caller requested ``target_type=zip`` (e.g. to retrieve
|
Used when the caller requested ``target_type=zip`` (e.g. to retrieve
|
||||||
picture image bytes that docling-serve only emits as referenced files
|
picture image bytes that docling-serve only emits as referenced files
|
||||||
bundled into a zip archive). The submit/poll flow is identical to
|
bundled into a zip archive). The submit/poll flow is identical to
|
||||||
``submit_and_poll``; only the result-fetching step differs.
|
``submit_and_poll`` (including circuit-breaker bookkeeping); only the
|
||||||
|
result-fetching step differs.
|
||||||
"""
|
"""
|
||||||
headers = self._get_headers()
|
headers = self._get_headers()
|
||||||
base_url = self._pick_url()
|
base_url = self._pick_url()
|
||||||
async with self._httpx_client() as client:
|
try:
|
||||||
task_id = await self._submit_and_wait(
|
async with self._httpx_client() as client:
|
||||||
client, base_url, endpoint, files, data, headers, name
|
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_url = f"{base_url}/v1/result/{task_id}"
|
||||||
result_response.raise_for_status()
|
result_response = await client.get(result_url, headers=headers)
|
||||||
return result_response.content
|
result_response.raise_for_status()
|
||||||
|
result = result_response.content
|
||||||
|
except Exception as exc:
|
||||||
|
self._record_outcome(base_url, exc)
|
||||||
|
raise
|
||||||
|
self._breaker_for(base_url).record_success()
|
||||||
|
return result
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,129 @@ def test_round_robin_shared_across_fresh_clients():
|
||||||
assert picks == [urls[0], urls[1], urls[2], urls[0]]
|
assert picks == [urls[0], urls[1], urls[2], urls[0]]
|
||||||
|
|
||||||
|
|
||||||
|
def _health_transport(
|
||||||
|
down: set[str], task_id: str, result: dict
|
||||||
|
) -> tuple[httpx.MockTransport, list[str]]:
|
||||||
|
"""MockTransport where requests to a host in the mutable ``down`` set raise
|
||||||
|
ConnectError (a crashed instance); other hosts serve a normal trio. The
|
||||||
|
caller can mutate ``down`` mid-test to flip an instance's health. Records
|
||||||
|
every host attempted."""
|
||||||
|
seen: list[str] = []
|
||||||
|
routes = _success_routes(task_id, result)
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
seen.append(request.url.host)
|
||||||
|
if request.url.host in down:
|
||||||
|
raise httpx.ConnectError("connection refused", request=request)
|
||||||
|
return routes.get(request.url.path, httpx.Response(404))
|
||||||
|
|
||||||
|
return httpx.MockTransport(handler), seen
|
||||||
|
|
||||||
|
|
||||||
|
async def _poll(client: DoclingServeClient) -> None:
|
||||||
|
"""Issue one submit_and_poll, swallowing the transport error raised when the
|
||||||
|
picked instance is down (this baseline has no in-request failover yet)."""
|
||||||
|
try:
|
||||||
|
await client.submit_and_poll(
|
||||||
|
endpoint="/v1/convert/file/async",
|
||||||
|
files={"file": ("x.md", b"x", "text/markdown")},
|
||||||
|
data={},
|
||||||
|
)
|
||||||
|
except httpx.TransportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_open_breaker_skips_crashed_instance():
|
||||||
|
"""Once an instance has failed enough to open its breaker, _pick_url skips
|
||||||
|
it — later requests route straight to a healthy instance without even
|
||||||
|
attempting the dead one."""
|
||||||
|
down = {"crash-x"}
|
||||||
|
transport, seen = _health_transport(down, "t", {"ok": True})
|
||||||
|
client = DoclingServeClient(
|
||||||
|
base_urls=["http://crash-x:5001", "http://live-x:5001"],
|
||||||
|
transport=transport,
|
||||||
|
breaker_failure_threshold=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
await _poll(client) # picks crash-x → fail (failures=1)
|
||||||
|
await _poll(client) # picks live-x → ok
|
||||||
|
await _poll(client) # picks crash-x → fail (failures=2 → breaker opens)
|
||||||
|
|
||||||
|
assert client._breaker_for("http://crash-x:5001").is_open
|
||||||
|
|
||||||
|
seen.clear()
|
||||||
|
await _poll(client)
|
||||||
|
await _poll(client)
|
||||||
|
assert "crash-x" not in seen
|
||||||
|
assert set(seen) == {"live-x"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_breaker_recovers_after_cooldown():
|
||||||
|
"""An open breaker auto-probes after its cooldown; once the instance is
|
||||||
|
healthy again a successful request closes the breaker."""
|
||||||
|
clock = [1000.0]
|
||||||
|
down = {"flip-y"}
|
||||||
|
transport, seen = _health_transport(down, "t", {"ok": True})
|
||||||
|
flip = "http://flip-y:5001"
|
||||||
|
client = DoclingServeClient(
|
||||||
|
base_urls=[flip, "http://spare-y:5001"],
|
||||||
|
transport=transport,
|
||||||
|
breaker_failure_threshold=2,
|
||||||
|
breaker_cooldown_s=30.0,
|
||||||
|
now_fn=lambda: clock[0],
|
||||||
|
)
|
||||||
|
|
||||||
|
await _poll(client) # flip-y → fail (1)
|
||||||
|
await _poll(client) # spare-y → ok
|
||||||
|
await _poll(client) # flip-y → fail (2 → open)
|
||||||
|
assert client._breaker_for(flip).is_open
|
||||||
|
|
||||||
|
# Instance recovers, but within the cooldown it's still treated as open.
|
||||||
|
down.clear()
|
||||||
|
assert client._breaker_for(flip).is_open
|
||||||
|
|
||||||
|
# After the cooldown the breaker allows a probe again.
|
||||||
|
clock[0] += 31.0
|
||||||
|
assert not client._breaker_for(flip).is_open
|
||||||
|
|
||||||
|
# Traffic can return to flip-y and success closes the breaker for good.
|
||||||
|
seen.clear()
|
||||||
|
for _ in range(2):
|
||||||
|
await _poll(client)
|
||||||
|
assert "flip-y" in seen
|
||||||
|
assert not client._breaker_for(flip).is_open
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_4xx_does_not_trip_breaker():
|
||||||
|
"""A 4xx is the caller's fault — it must not count against instance health,
|
||||||
|
even with a 1-failure threshold."""
|
||||||
|
seen: list[str] = []
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
seen.append(request.url.host)
|
||||||
|
return httpx.Response(400, json={"detail": "bad request"})
|
||||||
|
|
||||||
|
bad = "http://bad-z:5001"
|
||||||
|
client = DoclingServeClient(
|
||||||
|
base_urls=[bad],
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
breaker_failure_threshold=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
for _ in range(3):
|
||||||
|
with pytest.raises(httpx.HTTPStatusError):
|
||||||
|
await client.submit_and_poll(
|
||||||
|
endpoint="/v1/convert/file/async",
|
||||||
|
files={"file": ("x.md", b"x", "text/markdown")},
|
||||||
|
data={},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not client._breaker_for(bad).is_open
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_zip_endpoint_uses_round_robin_too():
|
async def test_zip_endpoint_uses_round_robin_too():
|
||||||
transport, seen = _scripted_transport(
|
transport, seen = _scripted_transport(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue