fix(proxy): keep Windows on single-worker reload and pass unlisted uvicorn args to the reload pool

This commit is contained in:
mateo-berri 2026-10-20 22:37:29 -07:00
parent dc68ca731b
commit d8cc9ed90a
3 changed files with 26 additions and 8 deletions

View file

@ -17,6 +17,7 @@ from uvicorn import config as uvicorn_config
from uvicorn.supervisors import ChangeReload, Multiprocess
@with_config(ConfigDict(extra="allow"))
class ServerOptions(TypedDict, total=False):
host: ReadOnly[str]
port: ReadOnly[int]
@ -34,7 +35,7 @@ class ServerOptions(TypedDict, total=False):
loop: ReadOnly[Literal["none", "auto", "asyncio", "uvloop"]]
@with_config(ConfigDict(extra="forbid"))
@with_config(ConfigDict(extra="allow"))
class ReloadOptions(ServerOptions):
app: Required[ReadOnly[str]]
reload: ReadOnly[bool]
@ -47,10 +48,17 @@ SERVER_OPTIONS: Final = TypeAdapter(ServerOptions)
ASGI_APP: Final = TypeAdapter(ImportString[Callable[..., object]])
LOGGER: Final = logging.getLogger("uvicorn.error")
RELOAD_WORKER_HEALTHCHECK_SECONDS: Final = 30
RELOADER_ONLY_KEYS: Final = frozenset(ReloadOptions.__annotations__) - frozenset(ServerOptions.__annotations__)
def multi_worker_reload_supported() -> bool:
return hasattr(uvicorn_config, "STARTUP_FAILURE")
def multi_worker_reload_supported(platform: str = sys.platform) -> bool:
return platform != "win32" and hasattr(uvicorn_config, "STARTUP_FAILURE")
def server_options(options: ReloadOptions) -> ServerOptions:
return SERVER_OPTIONS.validate_python(
{key: value for key, value in options.items() if key not in RELOADER_ONLY_KEYS}
)
def load_app_or_exit(import_path: str) -> Callable[..., object]:
@ -68,7 +76,9 @@ def pool_config(options: ReloadOptions, workers: int) -> Config:
partial(load_app_or_exit, options["app"]),
factory=True,
workers=workers,
**SERVER_OPTIONS.validate_python({"timeout_worker_healthcheck": RELOAD_WORKER_HEALTHCHECK_SECONDS, **options}), # pyright: ignore[reportArgumentType] # uvicorn types the float graceful shutdown timeout as int
**SERVER_OPTIONS.validate_python(
{"timeout_worker_healthcheck": RELOAD_WORKER_HEALTHCHECK_SECONDS, **server_options(options)}
), # pyright: ignore[reportArgumentType] # uvicorn types the float graceful shutdown timeout as int
)
@ -109,7 +119,7 @@ def run_multi_worker_reload(options: ReloadOptions, workers: int) -> None:
reload=options["reload"],
reload_dirs=list(options["reload_dirs"]),
reload_includes=list(options["reload_includes"]),
**SERVER_OPTIONS.validate_python(options), # pyright: ignore[reportArgumentType] # uvicorn types the float graceful shutdown timeout as int
**server_options(options), # pyright: ignore[reportArgumentType] # uvicorn types the float graceful shutdown timeout as int
)
shared_socket: Final = worker_config.bind_socket()
try:

View file

@ -1070,7 +1070,7 @@ class ProxyInitializationHelpers:
"--reload",
is_flag=True,
default=False,
help="Enable uvicorn hot reload (dev only), including every --num_workers worker on uvicorn>=0.50.0. Also reloads when the --config YAML or .env changes. Incompatible with --run_gunicorn, --run_hypercorn, and --run_granian.",
help="Enable uvicorn hot reload (dev only), including every --num_workers worker on uvicorn>=0.50.0 outside Windows. Also reloads when the --config YAML or .env changes. Incompatible with --run_gunicorn, --run_hypercorn, and --run_granian.",
)
@click.option(
"--prometheus_metrics_port",
@ -1655,8 +1655,8 @@ def run_server(
)
return
print(
"\033[1;33mLiteLLM Proxy: --reload with --num_workers>1 requires uvicorn>=0.50.0, "
"so only a single worker reloads.\033[0m"
"\033[1;33mLiteLLM Proxy: --reload with --num_workers>1 requires uvicorn>=0.50.0 "
"and an OS other than Windows, so only a single worker reloads.\033[0m"
)
uvicorn.run(
**uvicorn_args,

View file

@ -171,6 +171,14 @@ def test_the_reload_pool_accepts_and_keeps_every_uvicorn_arg_the_proxy_cli_build
assert {key: getattr(config, key) for key in server_args} == server_args
def test_the_reload_pool_passes_a_uvicorn_arg_it_does_not_list_through_to_the_workers() -> None:
config: Final = pool_config(RELOAD_OPTIONS.validate_python({**_options("app:app"), "backlog": 123}), workers=POOL_WORKERS)
assert (config.backlog, config.reload) == (123, False)
def test_windows_keeps_single_worker_reload_whatever_the_uvicorn_version() -> None:
assert not multi_worker_reload_supported("win32")
def test_the_pool_is_told_to_stop_once_the_reloader_process_exits() -> None:
reloader: Final = multiprocessing.get_context("spawn").Process(target=time.sleep, args=(POOL_TIMEOUT_SECONDS,))
reloader.start()