improve polling for FLUX3 video gen (#75010)

* wait between polls
This commit is contained in:
rob-maron 2026-07-30 16:29:27 -04:00 • committed by GitHub
parent c9de69c6d5
commit 4a798f4bce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 167 additions and 17 deletions

View file

@ -44,10 +44,14 @@ class _FakeResponse:
class _FakeClient:
"""Captures the one request each handler makes."""
"""Captures each request a handler makes.
A list of responses is served in order, with the last one repeating, so a
poll that looks twice can be given a job that finishes between looks.
"""
def __init__(self, response, sink):
self._response = response
self._responses = list(response) if isinstance(response, list) else [response]
self._sink = sink
async def __aenter__(self):
@ -58,9 +62,10 @@ class _FakeClient:
async def request(self, method, url, headers=None, json=None):
self._sink.append({"method": method, "url": url, "headers": headers or {}, "json": json})
if isinstance(self._response, Exception):
raise self._response
return self._response
response = self._responses[min(len(self._sink) - 1, len(self._responses) - 1)]
if isinstance(response, Exception):
raise response
return response
class _FakeStream:
@ -115,6 +120,13 @@ def _run(coro):
return asyncio.run(coro)
def _record_sleep(sink):
async def _sleep(seconds):
sink.append(seconds)
return _sleep
def _call(handler, args, response, headers=None):
"""Invoke a handler with the transport stubbed; returns (parsed, requests)."""
sink = []
@ -249,18 +261,82 @@ class TestSubmitTransport:
assert "error" in parsed
@pytest.fixture(autouse=True)
def _no_real_poll_wait(monkeypatch):
"""Keep the in-call wait out of the test clock."""
monkeypatch.setattr(flux3, "_POLL_FOLLOW_UP_WAIT_SECONDS", 0)
class TestPollTransport:
def test_poll_gets_the_job_and_returns_guidance(self):
response = _FakeResponse(200, {"id": "bfl_job_1", "status": "Generating", "guidance": "Still going."})
def test_a_terminal_status_returns_at_once_without_waiting(self):
response = _FakeResponse(200, {"id": "bfl_job_1", "status": "Error", "guidance": "The job is over."})
parsed, requests = _call(flux3._handle_get_result, {"id": "bfl_job_1"}, response)
assert requests[0]["method"] == "GET"
assert requests[0]["url"] == f"{BASE_URL}/generations/bfl_job_1"
assert requests[0]["json"] is None
assert parsed["result"] == "Still going."
assert len(requests) == 1
assert parsed["result"] == "The job is over."
def test_a_running_job_is_waited_out_inside_the_call(self, monkeypatch):
# A model has no clock, so telling it to pause produced a burst of polls
# instead of a paced one. The wait lives here where it cannot be skipped.
monkeypatch.setattr(flux3, "_POLL_FOLLOW_UP_WAIT_SECONDS", 45.0)
running = _FakeResponse(200, {"id": "bfl_job_1", "status": "Generating", "guidance": "Still going."})
slept = []
with patch.object(flux3.asyncio, "sleep", new=_record_sleep(slept)):
parsed, requests = _call(flux3._handle_get_result, {"id": "bfl_job_1"}, running)
assert len(requests) == 2, "should look again after waiting"
assert sum(slept) == 45.0
assert parsed["details"]["status"] == "Generating"
def test_the_wait_is_answerable_to_a_stop(self, monkeypatch):
# Nothing outside the tool can end a call that has already started —
# the executor only checks for an interrupt between tools — so /stop
# has to land inside the wait rather than at the end of it.
monkeypatch.setattr(flux3, "_POLL_FOLLOW_UP_WAIT_SECONDS", 45.0)
from tools import interrupt as interrupt_module
running = _FakeResponse(200, {"id": "bfl_job_1", "status": "Generating", "guidance": "Still going."})
looks = []
def _stop_after_one_slice():
looks.append(True)
return len(looks) > 1
slept = []
with patch.object(interrupt_module, "is_interrupted", _stop_after_one_slice), \
patch.object(flux3.asyncio, "sleep", new=_record_sleep(slept)):
parsed, requests = _call(flux3._handle_get_result, {"id": "bfl_job_1"}, running)
assert slept == [flux3._POLL_WAIT_SLICE_SECONDS], "the rest of the wait is abandoned"
assert len(requests) == 1, "and so is the second look"
assert parsed["details"]["status"] == "Generating"
def test_the_call_returns_as_soon_as_the_job_finishes(self):
# The point of waiting in here is that the caller gets the result on the
# wait it was already taking, not one round trip later.
running = _FakeResponse(200, {"id": "bfl_job_1", "status": "Generating", "guidance": "Still going."})
done = _FakeResponse(200, {"id": "bfl_job_1", "status": "Error", "guidance": "That job failed."})
parsed, requests = _call(flux3._handle_get_result, {"id": "bfl_job_1"}, [running, done])
assert len(requests) == 2
assert parsed["result"] == "That job failed."
def test_a_refusal_is_returned_immediately_rather_than_waited_on(self):
# A 429 carries its own retry guidance; sleeping on it would only delay
# showing the model what to do, and spend the poll budget twice.
response = _FakeResponse(429, {"error": {"message": "Too many polls. Wait 30 seconds."}})
parsed, requests = _call(flux3._handle_get_result, {"id": "bfl_job_1"}, response)
assert len(requests) == 1
assert "Too many polls" in parsed["error"]
def test_ready_saves_the_clip_and_never_returns_the_signed_url(self, tmp_path):
# The signed URL is a bearer credential for the clip and it used to be
# re-keyed into a shell command by hand, dropping characters. Neither

View file

@ -179,6 +179,58 @@ _MEDIA_FIELDS = {
_MAX_IMAGES = 10
# How long one get_result call waits before its second look. The bound that
# matters is the whole call rather than this number: model_tools' async bridge
# abandons a tool at 300s, and one call spends two polls (each bounded
# server-side at 45s), this wait, and on Ready the download of the clip. There
# is room to roughly double this; the reason not to is that a finished job is
# only noticed at the next look, so the wait is also the notice delay.
_POLL_FOLLOW_UP_WAIT_SECONDS = 45.0
# Taken in slices so the wait is answerable. Nothing outside a tool can end a
# call that has already started — the executor only checks for an interrupt
# between tools — so a tool that blocks this long has to watch the flag itself.
_POLL_WAIT_SLICE_SECONDS = 1.0
# Mirrors the gateway's BFL statuses
_TERMINAL_POLL_STATUSES = frozenset(
{"Ready", "Error", "Request Moderated", "Content Moderated", "Task not found"}
)
def _poll_is_finished(raw: str) -> bool:
"""True when there is nothing to wait for: done, refused, or unreadable."""
try:
payload = json.loads(raw)
except Exception:
return True
if not isinstance(payload, dict) or "error" in payload:
# A refusal carries its own guidance (a wait, a limit, a dead job).
# Sleeping on it would only delay showing the model what to do.
return True
details = payload.get("details")
status = details.get("status") if isinstance(details, dict) else None
return not isinstance(status, str) or status in _TERMINAL_POLL_STATUSES
async def _wait_before_second_look() -> bool:
"""Hold the call open between looks; False if the user interrupted.
Counted down rather than clock-driven: this paces polling, so a slice
that runs long changes nothing, and the loop stays testable without a
fake clock.
"""
from tools.interrupt import is_interrupted
remaining = _POLL_FOLLOW_UP_WAIT_SECONDS
while remaining > 0:
if is_interrupted():
return False
this_slice = min(_POLL_WAIT_SLICE_SECONDS, remaining)
await asyncio.sleep(this_slice)
remaining -= this_slice
return True
def _warm_nous_token() -> None:
"""Refresh the Nous token once, before any parallel upload needs it.
@ -471,8 +523,25 @@ async def _handle_get_result(args: dict, **kwargs) -> str:
return _error("BFL video generation is not available in this build.")
from urllib.parse import quote
raw = await _call_gateway("GET", f"{endpoints['base_url']}/generations/{quote(job_id.strip(), safe='')}")
return await _save_if_ready(raw, (args or {}).get("save_to"))
url = f"{endpoints['base_url']}/generations/{quote(job_id.strip(), safe='')}"
save_to = (args or {}).get("save_to")
raw = await _call_gateway("GET", url)
if _poll_is_finished(raw):
return await _save_if_ready(raw, save_to)
# Still running, so absorb the wait here instead of asking the model to
# take it. A model has no clock: told to wait it emits "I'll wait a minute"
# and its next action lands immediately, so the guidance produced a burst of
# polls rather than a paced one. Waiting inside the call cannot be skipped,
# needs no shell, and works the same on every platform. One call therefore
# covers a couple of minutes and returns as soon as a look finds it done.
if not await _wait_before_second_look():
# Interrupted mid-wait: hand back the status we already have rather
# than spending a round trip the user has just asked us to stop for.
return await _save_if_ready(raw, save_to)
raw = await _call_gateway("GET", url)
return await _save_if_ready(raw, save_to)
async def _handle_prompting_guide(args: dict, **kwargs) -> str:
@ -682,8 +751,9 @@ GET_RESULT_SCHEMA = {
"name": "bfl_flux3_get_result",
"description": (
"Poll a FLUX 3 video job by the job id a generate tool returned. Generation takes minutes "
"and a long Generating phase is normal. Every response states the job's status, how long "
"to wait before polling again, and what you may do next — follow it rather than guessing. "
"and a long Generating phase is normal. This call waits for you while the job runs, so it "
"may take a couple of minutes; if it returns still generating, just call it again. Do not "
"sleep between calls. "
"On Ready the clip is downloaded for you and the response gives its local path; your only "
"remaining step is to deliver that file as the response describes."
),
@ -813,11 +883,15 @@ auto-downscaled and output tops out at 720p.
Submit returns a job id immediately — the video does not exist yet. Poll
bfl_flux3_get_result with that id; generation takes several minutes and a long
Generating phase is normal, not a stall. Nothing reaches disk before the job is
Ready, so checking folders mid-run tells you nothing. Every response
states the job status, how long to wait before polling again, and what you may
do next — follow it literally rather than guessing an interval. A job survives
client restarts: re-poll the same id rather than resubmitting, which would only
spend your budgets on duplicate work.
Ready, so checking folders mid-run tells you nothing.
The waiting is not yours to do. bfl_flux3_get_result takes the pause itself
while a job is still running, so one call can occupy a couple of minutes and
comes back the moment the job finishes. If it returns still generating, just
call it again — no sleeping, no interval to judge, nothing to time.
A job survives client restarts: re-poll the same id rather than resubmitting,
which would only spend your budgets on duplicate work.
## Save and deliver