Found while reviewing #376. Not reachable by typing into the Cluster Strength spinner — <input type="number"> sanitizes both Infinity and 1e400 to "" — but reachable from the URL, from any API client, and from a hand-edited config.
cluster_eps is declared as a bare float | None on /umap_data (photomap/backend/routers/umap.py:25) and on /cluster_labels, and as float | None on the set_umap_eps body model (photomap/backend/routers/album.py:26-32). FastAPI/Pydantic coerce inf, Infinity and 1e400 to float("inf"), and nan/NaN to float("nan"). Neither is rejected anywhere downstream.
nan — 500
GET /umap_data/<album>?cluster_eps=nan
resolve_cluster_eps passes it straight through (max(nan, MIN_CLUSTER_EPS) is nan, and while nan > MIN_BUDGETED_EPS is false), so it reaches the fit:
InvalidParameterError: The 'eps' parameter of DBSCAN must be a float in the range (0.0, inf). Got nan instead.
inf — a thread-pool worker that never comes back
GET /umap_data/<album>?cluster_eps=inf
_shrink_eps_to_pair_budget (photomap/backend/cluster_eps.py:163-186) loops:
while eps > MIN_BUDGETED_EPS:
pairs = int(tree.query_radius(coords, r=eps, count_only=True).sum())
if pairs <= MAX_NEIGHBOR_PAIRS:
return eps
eps *= 0.7
inf * 0.7 is inf, and with r=inf every point is every point's neighbour, so pairs is n² — over MAX_NEIGHBOR_PAIRS (50,000,000) for any album past ~7,071 images. Neither exit condition can ever be reached. Verified on an 8,000-point cloud: still looping after 20s, and it is a full query_radius pass per iteration, so it burns the asyncio.to_thread worker indefinitely. Enough of these and the server stops answering.
The storage path is worse because it persists: POST /set_umap_eps/ {"eps": 1e400} stores inf, which YAML round-trips as .inf, after which /get_umap_eps raises ValueError: Out of range float values are not JSON compliant and every /umap_data for that album hangs. The album needs its config hand-edited to recover.
Suggested fix
Constrain the value where it enters, rather than defending inside the resolver — #375 is already adding range validation for the same control, so this belongs with it: reject non-finite (and out-of-range) cluster_eps on /umap_data, /cluster_labels and set_umap_eps with a 422. A belt-and-braces math.isfinite guard in _shrink_eps_to_pair_budget would also stop the loop being unbounded on principle.
Found while reviewing #376. Not reachable by typing into the Cluster Strength spinner —
<input type="number">sanitizes bothInfinityand1e400to""— but reachable from the URL, from any API client, and from a hand-edited config.cluster_epsis declared as a barefloat | Noneon/umap_data(photomap/backend/routers/umap.py:25) and on/cluster_labels, and asfloat | Noneon theset_umap_epsbody model (photomap/backend/routers/album.py:26-32). FastAPI/Pydantic coerceinf,Infinityand1e400tofloat("inf"), andnan/NaNtofloat("nan"). Neither is rejected anywhere downstream.nan— 500resolve_cluster_epspasses it straight through (max(nan, MIN_CLUSTER_EPS)isnan, andwhile nan > MIN_BUDGETED_EPSis false), so it reaches the fit:inf— a thread-pool worker that never comes back_shrink_eps_to_pair_budget(photomap/backend/cluster_eps.py:163-186) loops:inf * 0.7isinf, and withr=infevery point is every point's neighbour, sopairsisn²— overMAX_NEIGHBOR_PAIRS(50,000,000) for any album past ~7,071 images. Neither exit condition can ever be reached. Verified on an 8,000-point cloud: still looping after 20s, and it is a fullquery_radiuspass per iteration, so it burns theasyncio.to_threadworker indefinitely. Enough of these and the server stops answering.The storage path is worse because it persists:
POST /set_umap_eps/ {"eps": 1e400}storesinf, which YAML round-trips as.inf, after which/get_umap_epsraisesValueError: Out of range float values are not JSON compliantand every/umap_datafor that album hangs. The album needs its config hand-edited to recover.Suggested fix
Constrain the value where it enters, rather than defending inside the resolver — #375 is already adding range validation for the same control, so this belongs with it: reject non-finite (and out-of-range)
cluster_epson/umap_data,/cluster_labelsandset_umap_epswith a 422. A belt-and-bracesmath.isfiniteguard in_shrink_eps_to_pair_budgetwould also stop the loop being unbounded on principle.