Files
FabledCurator/agent/fc_agent/client.py
T
bvandeusen ccbb5cbc9e
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m26s
fix(agent): stop the downloader pool stampeding a slow curator (congestion collapse)
Operator hit an outage after the machine slept overnight: the agent showed
"curator unreachable" in a loop while curator's API (lease) was actually fine and
the browser could still load images — just slowly. Root cause is a feedback loop
in the new pipeline: every download streams a full original through curator's
single Python file-serving path, and the autoscaler grows DOWNLOADERS whenever the
buffer is empty. When downloads are merely SLOW/failing, the buffer is empty for
that reason — so the agent piled on more concurrent large-file GETs, saturating
curator's web workers + NFS, which slowed curator (and its browser) further and
produced more failures → more downloaders. Classic congestion collapse.

- Failure-aware autoscaling: if transient download failures rose since the last
  decision, SHRINK the downloader pool toward the floor instead of growing — the
  empty buffer is caused by failures, not the GPU starving. It ramps back up only
  once downloads succeed again.
- DL_MAX 24 → 8: 24 concurrent large-file downloads through one Python serving
  path is too many; 8 keeps a fast GPU fed without stampeding curator.
- fetch_image timeout 180 → (10, 60): the read timeout is between-bytes, so a
  large-but-flowing download still completes, but a stuck/dead connection fails in
  60s instead of hanging a downloader for 3 min and piling up stuck requests.

Build marker 2026-07-01.4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
2026-07-01 10:33:18 -04:00

130 lines
5.2 KiB
Python

"""HTTP client for the FabledCurator GPU-job API.
The agent's ONLY contact with FC — lease/submit/heartbeat/fail + fetch image
bytes, all over HTTP with the bearer token. No DB/Redis.
"""
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
class FcClient:
def __init__(self, base_url: str, token: str, agent_id: str):
self.base = base_url.rstrip("/")
self.agent_id = agent_id
# Main session: NO in-request retry — lease/fetch are cheap to redo and
# the worker loop already backs off + re-leases on failure. (Auto-retrying
# a lease could double-claim a batch if a response is lost.)
self.s = self._session(token)
# Submit session: retry in-place, because by submit time the GPU work is
# already DONE — a momentary blip (dropped connection, gateway 5xx during
# a curator redeploy) must not throw that work away and force a full
# re-download + recompute on another agent. A duplicate submit after a
# lost response is harmless: the job is already closed, so it just returns
# 409 lease_invalid (a no-op). Idempotent enough to retry POST safely.
retry = Retry(
total=3, connect=3, read=3, status=3,
backoff_factor=0.5, # ~0.5s, 1s, 2s between tries
status_forcelist=(500, 502, 503, 504), # transient server/gateway
allowed_methods=frozenset({"POST"}),
raise_on_status=False, # let raise_for_status decide
)
self._submit_s = self._session(token, retry)
@staticmethod
def _session(token: str, retry: Retry | None = None) -> requests.Session:
s = requests.Session()
s.headers["Authorization"] = f"Bearer {token}"
# Many worker threads share a Session; the default pool (10) would
# throttle them + spam "connection pool is full". Size it for the cap.
adapter = HTTPAdapter(
pool_connections=64, pool_maxsize=64, max_retries=retry or 0
)
s.mount("http://", adapter)
s.mount("https://", adapter)
return s
def lease(self, batch_size: int) -> list[dict]:
r = self.s.post(
f"{self.base}/api/gpu/jobs/lease",
json={"agent_id": self.agent_id, "batch_size": batch_size},
timeout=30,
)
r.raise_for_status()
return r.json().get("jobs", [])
def submit(self, job_id: int, regions: list[dict], replace_kinds: list[str]) -> dict:
r = self._submit_s.post(
f"{self.base}/api/gpu/jobs/submit",
json={
"agent_id": self.agent_id, "job_id": job_id,
"regions": regions, "replace_kinds": replace_kinds,
},
timeout=120,
)
r.raise_for_status()
return r.json()
def submit_embedding(self, job_id: int, embedding: list, version: str) -> dict:
"""Post a whole-image SigLIP embedding (the 'embed' task) → image_record."""
r = self._submit_s.post(
f"{self.base}/api/gpu/jobs/submit_embedding",
json={
"agent_id": self.agent_id, "job_id": job_id,
"embedding": embedding, "embedding_version": version,
},
timeout=120,
)
r.raise_for_status()
return r.json()
def heartbeat(self, job_ids: list[int]) -> None:
try:
self.s.post(
f"{self.base}/api/gpu/jobs/heartbeat",
json={"agent_id": self.agent_id, "job_ids": job_ids},
timeout=30,
)
except requests.RequestException:
pass
def fail(self, job_id: int, error: str) -> None:
try:
self.s.post(
f"{self.base}/api/gpu/jobs/fail",
json={"agent_id": self.agent_id, "job_id": job_id, "error": error},
timeout=30,
)
except requests.RequestException:
pass
def release(self, job_ids: list[int]) -> None:
# Graceful hand-back on stop so orphaned work is re-leased at once.
if not job_ids:
return
try:
self.s.post(
f"{self.base}/api/gpu/jobs/release",
json={"agent_id": self.agent_id, "job_ids": job_ids},
timeout=30,
)
except requests.RequestException:
pass
def fetch_image(self, image_url: str) -> bytes:
# image_url is a server-relative path ("/images/...").
# timeout=(connect, read): the read timeout is BETWEEN-BYTES, not total,
# so a large-but-flowing download still completes — but a stuck/dead
# connection (curator overloaded) fails in 60s instead of hanging a
# downloader for 180s and piling up concurrent stuck requests on curator.
r = self.s.get(f"{self.base}{image_url}", timeout=(10, 60))
r.raise_for_status()
return r.content
def queue_status(self) -> dict:
# Short timeout: this backs the UI /status poll, so a busy curator must
# not hang the page for long (the GPU meters poll /gpu separately).
r = self.s.get(f"{self.base}/api/gpu/status", timeout=5)
r.raise_for_status()
return r.json()