feat(lfx): add --timeout flag for lfx serve worker timeout

Expose gunicorn's worker timeout (previously hardcoded at 120s) as --timeout, on both serve_command and the CLI wrapper. A worker that doesn't complete a request within --timeout seconds is killed and restarted.

Matters most under --sync-workers: a blocking sync worker cannot heartbeat mid-request, so long-running flows need a higher timeout. Default stays 120s (unchanged behavior); no effect on the Windows uvicorn fallback.
This commit is contained in:
Jordan Frazier
2026-06-06 08:09:43 -04:00
parent cb81d4ed79
commit 5acc032c15
4 changed files with 43 additions and 4 deletions

View File

@ -263,6 +263,7 @@ To view the LFX server's API docs and schema, see the `/docs` endpoint at `http:
| `--flow-dir` | Directory for filesystem-backed flow storage shared across workers (e.g. `/tmp/lfx-flows` for single-pod, or a PVC mount for cross-pod). Defaults to in-memory only when omitted. |
| `--max-requests` | Recycle each worker after this many requests (gunicorn, Unix-only, `--workers > 1`); `1` gives per-request recycling. Default: unset (workers are never recycled). |
| `--limit-concurrency` | Max in-flight requests per worker (`--workers > 1`); excess get HTTP 503. Default: unset (unlimited). |
| `--timeout` | Worker timeout in seconds (gunicorn, Unix, `--workers > 1`): a worker that doesn't finish a request in this many seconds is killed and restarted. Raise it for long flows, especially with `--sync-workers`. Default: `120`. No effect on Windows. |
| `--no-env-fallback` / `--env-fallback` | Disable the `os.environ` fallback for credential variables; variables not supplied via `global_vars` per request resolve to `None` instead of reading the process environment. Default: `--env-fallback` (fallback enabled). |
| `--reset-environ` / `--no-reset-environ` | Snapshot `os.environ` before each flow run and restore it afterward, so a flow's environment mutations (or request-scoped credentials) cannot leak into the next request served by the same warm worker. Default: `--no-reset-environ` (off). |
| `--sync-workers` / `--no-sync-workers` | Multi-worker only (`--workers > 1`, Unix). Use gunicorn's blocking `sync` worker so the kernel routes each request to an idle worker instead of queueing it behind an in-flight request on a busy async worker. Requires the `a2wsgi` package (`pip install a2wsgi`). Default: `--no-sync-workers` (async worker). |

View File

@ -157,6 +157,16 @@ def register(app: typer.Typer) -> None:
"strict cross-request isolation. Default (unset) means unlimited concurrency."
),
),
timeout: int = typer.Option(
120,
"--timeout",
help=(
"Worker timeout in seconds (gunicorn, Unix, --workers > 1): a worker that does not "
"complete a request within this many seconds is killed and restarted. Raise it for "
"long-running flows, especially with --sync-workers (a blocking sync worker cannot "
"heartbeat mid-request). Default: 120. No effect on Windows (uvicorn fallback)."
),
),
*,
stdin: bool = typer.Option(
False,
@ -222,4 +232,5 @@ def register(app: typer.Typer) -> None:
limit_concurrency=limit_concurrency,
reset_environ=reset_environ,
sync_workers=sync_workers,
timeout=timeout,
)

View File

@ -201,6 +201,16 @@ def serve_command(
"strict cross-request isolation. Default (unset) means unlimited concurrency."
),
),
timeout: int = typer.Option(
120,
"--timeout",
help=(
"Worker timeout in seconds (gunicorn, Unix, --workers > 1): a worker that does not "
"complete a request within this many seconds is killed and restarted. Raise it for "
"long-running flows, especially with --sync-workers (a blocking sync worker cannot "
"heartbeat mid-request). Default: 120. No effect on Windows (uvicorn fallback)."
),
),
*,
stdin: bool = typer.Option(
False, # noqa: FBT003
@ -404,6 +414,7 @@ def serve_command(
limit_concurrency=limit_concurrency,
reset_environ=reset_environ,
sync_workers=sync_workers,
timeout=timeout,
)
else:
from lfx.cli.serve_app import _SERVE_RESET_ENVIRON_ENV
@ -442,6 +453,7 @@ def _launch_workers(
limit_concurrency: int | None,
reset_environ: bool = False,
sync_workers: bool = False,
timeout: int = 120,
) -> None:
"""Launch ``workers`` worker processes for ``lfx serve --workers N``.
@ -469,7 +481,9 @@ def _launch_workers(
around every flow run (see ``guarded_execute``). ``sync_workers``
(``--sync-workers``, Unix only) swaps the async worker for gunicorn's blocking
``sync`` worker wrapped by an a2wsgi ASGI->WSGI bridge, so the kernel routes each
request to an idle worker. Both default off.
request to an idle worker. Both default off. ``timeout`` (``--timeout``, default
120s) sets gunicorn's worker timeout — raise it for long flows, especially under
``--sync-workers``.
"""
from lfx.cli.serve_app import (
_SERVE_FLOW_DIR_ENV,
@ -569,9 +583,10 @@ def _launch_workers(
"max_requests": max_requests if max_requests is not None else 0,
"max_requests_jitter": 0,
"loglevel": log_level,
# Sized for long synchronous flows; gunicorn's default 30s timeout
# would kill long LLM flows. Expose --timeout later if needed.
"timeout": 120,
# Worker timeout (--timeout, default 120). gunicorn's own default is 30s,
# which would kill long LLM flows — especially under --sync-workers, where a
# blocking worker cannot heartbeat mid-request.
"timeout": timeout,
},
).run()
finally:

View File

@ -557,3 +557,15 @@ def test_launch_workers_reset_environ_off_by_default(monkeypatch):
captured = _capture_gunicorn_launch(monkeypatch)
assert captured["env"].get(_SERVE_RESET_ENVIRON_ENV) == "0"
def test_launch_workers_timeout_defaults_to_120(monkeypatch):
"""Without --timeout, gunicorn's worker timeout is 120s (long-flow safe default)."""
captured = _capture_gunicorn_launch(monkeypatch)
assert captured["options"]["timeout"] == 120
def test_launch_workers_timeout_propagates(monkeypatch):
"""--timeout N is forwarded to gunicorn's worker timeout."""
captured = _capture_gunicorn_launch(monkeypatch, timeout=600)
assert captured["options"]["timeout"] == 600