Skip to content

Commit bc96b33

Browse files
authored
feat(handler): on-the-fly package install for deployed mode (#77)
* feat(handler): on-the-fly package install for deployed mode When a deployed handler fails to import due to a missing package (e.g. excluded from the build artifact), the worker now attempts to install it via DependencyInstaller and retries the handler load. This prevents fatal crashes for CPU endpoints that need packages not in the slim base image. Logs a warning on each on-the-fly install so users are aware of the cold start penalty and can add the package to their dependencies list. Capped at 3 recovery attempts with a guard against retrying the same package, preventing unbounded install loops. * fix(review): address PR feedback for #77 - Restrict recovery to ModuleNotFoundError instead of broad ImportError - Fix off-by-one: add extra exec attempt after final install (MAX+1 iterations) - Add importlib.invalidate_caches() after on-the-fly install - Improve error message for failed recovery (actionable, not speculative) - Add explicit spec/loader assertions in test for clearer failure diagnostics - Update tests to raise ModuleNotFoundError to match new except clause
1 parent de07720 commit bc96b33

3 files changed

Lines changed: 170 additions & 12 deletions

File tree

src/constants.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,12 @@
5050
"""Number of times the Flash-deployed endpoint will attempt to unpack the worker-flash tarball from mounted volume."""
5151
DEFAULT_TARBALL_UNPACK_INTERVAL = 30
5252
"""Time in seconds the Flash-deployed endpoint will wait between tarball unpack attempts."""
53+
54+
# Dependency Recovery Configuration
55+
MAX_IMPORT_RECOVERY_ATTEMPTS = 3
56+
"""Max on-the-fly package installs before giving up during handler loading.
57+
58+
When a deployed handler fails to import due to a missing package, the worker
59+
attempts to install it and retry. This caps the retry loop to prevent unbounded
60+
installs (e.g. a package with many missing transitive deps).
61+
"""

src/handler.py

Lines changed: 114 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import importlib.util
22
import logging
33
import os
4+
import sys
45
from pathlib import Path
56
from typing import Any, Dict, Optional
67

8+
from constants import MAX_IMPORT_RECOVERY_ATTEMPTS
79
from logger import setup_logging
810
from unpack_volume import maybe_unpack
911
from version import format_version_banner
@@ -26,13 +28,121 @@ def _is_deployed_mode() -> bool:
2628
return bool(os.getenv("FLASH_RESOURCE_NAME"))
2729

2830

31+
class _HandlerRecoveryError(RuntimeError):
32+
"""Raised by _exec_handler_module when on-the-fly recovery fails.
33+
34+
Distinguished from generic RuntimeError so _load_generated_handler can
35+
re-raise it without wrapping, while still wrapping user-code RuntimeErrors.
36+
"""
37+
38+
39+
def _extract_missing_package(error: ImportError) -> str | None:
40+
"""Extract the top-level package name from an ImportError.
41+
42+
Returns the root package name (e.g. 'numpy' from 'numpy.core') or None
43+
if the module name cannot be determined.
44+
"""
45+
module_name: str | None = getattr(error, "name", None)
46+
if not module_name:
47+
return None
48+
return module_name.split(".")[0]
49+
50+
51+
def _try_install_missing_package(package_name: str) -> bool:
52+
"""Attempt to install a missing package on-the-fly via DependencyInstaller.
53+
54+
Returns True if installation succeeded, False otherwise.
55+
"""
56+
from dependency_installer import DependencyInstaller
57+
58+
installer = DependencyInstaller()
59+
result = installer.install_dependencies([package_name])
60+
return bool(result.success)
61+
62+
63+
def _exec_handler_module(
64+
spec: importlib.machinery.ModuleSpec,
65+
handler_file: Path,
66+
) -> Any:
67+
"""Execute a handler module spec, installing missing packages on-the-fly.
68+
69+
When a deployed handler fails to import due to a missing package (e.g.
70+
numpy excluded from the build artifact but needed at runtime), this
71+
function installs the package and retries. This adds to cold start time
72+
but prevents a fatal crash.
73+
74+
Returns:
75+
The loaded module object.
76+
77+
Raises:
78+
_HandlerRecoveryError: If the handler cannot be loaded after recovery attempts.
79+
"""
80+
installed_packages: list[str] = []
81+
82+
for _attempt in range(MAX_IMPORT_RECOVERY_ATTEMPTS + 1):
83+
mod = importlib.util.module_from_spec(spec)
84+
try:
85+
spec.loader.exec_module(mod) # type: ignore[union-attr]
86+
return mod
87+
except ModuleNotFoundError as e:
88+
if len(installed_packages) >= MAX_IMPORT_RECOVERY_ATTEMPTS:
89+
raise _HandlerRecoveryError(
90+
f"Generated handler {handler_file} failed to load after "
91+
f"installing {len(installed_packages)} missing packages: "
92+
f"{installed_packages}. Too many missing dependencies — "
93+
f"redeploy with 'flash deploy'."
94+
) from e
95+
96+
package_name = _extract_missing_package(e)
97+
if not package_name or package_name in installed_packages:
98+
raise _HandlerRecoveryError(
99+
"Import is still failing after attempted automatic recovery "
100+
"or the missing dependency could not be determined. "
101+
"Inspect your handler and its dependencies, then redeploy "
102+
"with 'flash deploy'."
103+
) from e
104+
105+
logger.warning(
106+
"Package '%s' is not in the build artifact. Installing on-the-fly. "
107+
"This adds to cold start time — consider adding it to your "
108+
"dependencies list to include it in the build artifact.",
109+
package_name,
110+
)
111+
112+
if not _try_install_missing_package(package_name):
113+
raise _HandlerRecoveryError(
114+
f"Failed to install missing package '{package_name}'. "
115+
f"Generated handler {handler_file} cannot be loaded. "
116+
f"Redeploy with 'flash deploy'."
117+
) from e
118+
119+
installed_packages.append(package_name)
120+
# Clear the failed module from sys.modules so the retry gets a fresh import
121+
for key in list(sys.modules):
122+
if key == package_name or key.startswith(f"{package_name}."):
123+
del sys.modules[key]
124+
importlib.invalidate_caches()
125+
logger.info("Installed '%s', retrying handler load", package_name)
126+
127+
raise _HandlerRecoveryError(
128+
f"Generated handler {handler_file} failed to load after installing "
129+
f"{len(installed_packages)} missing packages: {installed_packages}. "
130+
f"Too many missing dependencies — redeploy with 'flash deploy'."
131+
)
132+
133+
29134
def _load_generated_handler() -> Optional[Any]:
30135
"""Load Flash-generated handler for deployed QB mode.
31136
32137
Checks for a handler_<resource_name>.py file generated by the flash
33138
build pipeline. These handlers accept plain JSON input without
34139
FunctionRequest/cloudpickle serialization.
35140
141+
If the handler fails to import due to a missing package, attempts
142+
on-the-fly installation before giving up. This handles cases where
143+
a package was excluded from the build artifact (e.g. size-prohibitive
144+
packages) but is needed at runtime.
145+
36146
In deployed mode (FLASH_RESOURCE_NAME set), failures are fatal.
37147
FunctionRequest fallback is only valid for Live Serverless workers.
38148
@@ -68,20 +178,16 @@ def _load_generated_handler() -> Optional[Any]:
68178
f"The file may be corrupted. Redeploy with 'flash deploy'."
69179
)
70180

71-
mod = importlib.util.module_from_spec(spec)
72181
try:
73-
spec.loader.exec_module(mod)
74-
except ImportError as e:
75-
raise RuntimeError(
76-
f"Generated handler {handler_file} failed to import: {e}. "
77-
f"This usually means a dependency was built for the wrong Python version. "
78-
f"Redeploy with 'flash deploy'."
79-
) from e
182+
mod = _exec_handler_module(spec, handler_file)
80183
except SyntaxError as e:
81184
raise RuntimeError(
82185
f"Generated handler {handler_file} has a syntax error: {e}. "
83186
f"This indicates a bug in the flash build pipeline."
84187
) from e
188+
except _HandlerRecoveryError:
189+
# Recovery-specific RuntimeErrors from _exec_handler_module — already formatted
190+
raise
85191
except Exception as e:
86192
raise RuntimeError(
87193
f"Generated handler {handler_file} failed to load: {e} ({type(e).__name__}). "

tests/unit/test_handler.py

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -204,17 +204,60 @@ def test_raises_when_spec_creation_fails(self):
204204
with pytest.raises(RuntimeError, match="Failed to create module spec"):
205205
_load_generated_handler()
206206

207-
def test_raises_on_import_error(self, tmp_path):
208-
"""If generated handler has ImportError, raises RuntimeError."""
207+
def test_raises_on_import_error_when_install_fails(self, tmp_path):
208+
"""If install of missing package fails, raises with install failure message."""
209209
handler_file = tmp_path / "handler_gpu_config.py"
210210
handler_file.write_text(
211211
"from nonexistent_package import missing_function\ndef handler(event): pass\n"
212212
)
213213

214214
with patch.dict("os.environ", {"FLASH_RESOURCE_NAME": "gpu_config"}):
215215
with patch("handler.Path", return_value=handler_file):
216-
with pytest.raises(RuntimeError, match="failed to import"):
217-
_load_generated_handler()
216+
with patch("handler._try_install_missing_package", return_value=False):
217+
with pytest.raises(RuntimeError, match="Failed to install"):
218+
_load_generated_handler()
219+
220+
def test_recovery_installs_missing_package_and_retries(self, tmp_path):
221+
"""Successful on-the-fly install allows handler to load on retry."""
222+
from handler import _exec_handler_module
223+
224+
handler_file = tmp_path / "handler_gpu_config.py"
225+
handler_file.write_text("def handler(event): return {'recovered': True}\n")
226+
227+
import importlib.util
228+
229+
spec = importlib.util.spec_from_file_location("handler_gpu_config", handler_file)
230+
assert spec is not None, f"Failed to create module spec for {handler_file}"
231+
assert spec.loader is not None, f"Module spec has no loader for {handler_file}"
232+
233+
call_count = 0
234+
original_exec = spec.loader.exec_module
235+
236+
def exec_side_effect(module):
237+
nonlocal call_count
238+
call_count += 1
239+
if call_count == 1:
240+
raise ModuleNotFoundError("no module", name="fake_recovery_pkg")
241+
original_exec(module)
242+
243+
with patch.object(spec.loader, "exec_module", side_effect=exec_side_effect):
244+
with patch("handler._try_install_missing_package", return_value=True) as mock_install:
245+
mod = _exec_handler_module(spec, handler_file)
246+
assert hasattr(mod, "handler")
247+
assert callable(mod.handler)
248+
mock_install.assert_called_once_with("fake_recovery_pkg")
249+
250+
def test_recovery_stops_if_same_package_fails_twice(self, tmp_path):
251+
"""If the same package keeps failing after install, raises immediately."""
252+
handler_file = tmp_path / "handler_gpu_config.py"
253+
# Always raises ModuleNotFoundError for same package, even after "install"
254+
handler_file.write_text("raise ModuleNotFoundError('still missing', name='stubborn_pkg')\n")
255+
256+
with patch.dict("os.environ", {"FLASH_RESOURCE_NAME": "gpu_config"}):
257+
with patch("handler.Path", return_value=handler_file):
258+
with patch("handler._try_install_missing_package", return_value=True):
259+
with pytest.raises(RuntimeError, match="still failing after attempted"):
260+
_load_generated_handler()
218261

219262
def test_raises_on_syntax_error(self, tmp_path):
220263
"""SyntaxError in generated handler raises RuntimeError."""

0 commit comments

Comments
 (0)