Survive a busy engine: retry, idempotent submit, resilient waiting

A driver script died of one slow answer: httpx.ReadTimeout out of
RunHandle.refresh() with a 30 s read timeout and no retry anywhere, which
cost a sweep 78 of its 84 runs.

- Split the timeout (5 s connect, 120 s read): a wrong URL fails at once,
  and a busy engine gets longer than the slowest thing it does on purpose
  (a 60 s compile, a 15 s rebuild wait).
- Retry idempotent calls three times on a transport error or 502/503/504.
  503 is the engine's own "ask again" — it is what RebuildBusy answers.
- Submit carries a key the engine stores with the run, so a retry after a
  timeout returns that run instead of starting a second. A sweep keys every
  entry, so a half-created one recreates only what is missing.
- wait() and --follow tolerate five failed polls in a row; a 404 still stops
  at once, because that is an answer rather than a gap.
- CLI says "engine not answering" and names the run still on the engine,
  instead of printing a traceback.
- runs: clamp the params column to 80 characters; events() takes the
  flow/since/until the endpoint already had; RunHandle.failures answers
  "what killed this run" from the run's own node rows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 21:19:09 +02:00
co-authored by Claude Opus 5
parent 2cf45e4f39
commit 1f7c6646f1
12 changed files with 503 additions and 31 deletions
+54
View File
@@ -19,6 +19,7 @@ from fluksio.flow.runs import (
OUTPUT_CAP,
RunCache,
RunRejected,
RunService,
_cacheable,
new_run_id,
resolve_references,
@@ -349,6 +350,59 @@ def test_a_run_records_which_caller_asked_for_it(
assert refused.status_code == 422
class _Unusable:
"""Anything reaching this is something a deduplicated submit should not do."""
def __getattr__(self, name):
raise AssertionError(f"a repeated submit must not reach {name}")
def test_a_repeated_submit_returns_the_run_it_already_made():
"""The key is the answer to "did my first attempt land?".
Answered before the flow is even read: a caller retrying a submit it never
got a reply to is owed that run, whatever has been published since.
"""
service = RunService(controller=_Unusable(), queue=_Unusable())
with Session(db_engine) as session:
session.add(
Run(
id="dedup-1",
flow="study",
status="running",
idempotency_key="key-abc",
created_at=datetime.now(UTC),
)
)
session.commit()
again = service.submit("study", {"lr": 0.1}, idempotency_key="key-abc")
assert again.id == "dedup-1"
def test_a_key_nobody_used_submits_normally(
client, superuser_token_headers, monkeypatch
):
"""The route carries the key through; without one nothing changes."""
seen: dict[str, object] = {}
class Recorder:
def submit(self, name, **kwargs):
seen.update(kwargs)
return Run(id="keyed-1", flow=name, created_at=datetime.now(UTC))
monkeypatch.setattr(client.app.state, "run_service", Recorder())
answer = client.post(
f"{settings.API_V1_STR}/runs/flows/demo",
headers=superuser_token_headers,
json={"idempotency_key": "key-xyz"},
)
assert answer.status_code == 202
assert seen["idempotency_key"] == "key-xyz"
def test_overview_is_not_read_as_a_run_id(client, superuser_token_headers):
"""`/overview` is declared before `/{run_id}`, which would swallow it."""
answer = client.get(