Auto-generated nodes for the entire live fal catalog (1,391 models → 1,482 total nodes) built at startup from a committed registry, plus a full rewrite of the core layer. All 87 v1 node keys, inputs and outputs are preserved — existing workflows load and run unchanged. New: - data/fal_registry.json + scripts/build_registry.py: distills fal's catalog and per-endpoint OpenAPI schemas into a deterministic registry - nodes/dynamic/: schema→node factory (typed widgets, tooltips, pricing in node help, IMAGE/VIDEO/AUDIO auto-upload, native VIDEO/AUDIO outputs, seed -1=random, force_rerun), plus a generic Fal Any Endpoint node for arbitrary endpoints - nodes/utils/: rewritten core — FalApiError surfacing fal's real error payloads, queue progress logs, ComfyUI cancel support, retries and timeouts everywhere, parallel uploads/downloads, structured logging - tests/ (45 tests incl. a legacy-key snapshot lock), CI lint+test workflow, weekly registry-refresh workflow that opens a PR on changes - README regenerated from the registry (scripts/build_readme.py) Fixed: - GPTImage2 / GPTImage2Edit were defined but never registered - Topaz video upscale sent a nonexistent API field (desired_increase) - SeedVR video upscaler had its error handling commented out + wrong model label; ProRes enum value mismatch translated at argument level - 10x `is ""` comparisons, bare excepts, missing HTTP timeouts, temp-file leaks, fps=0 crash in LoadVideoURL, duplicate enum entry - opencv-python & friends missing from pyproject (Registry installs) - API failures now raise visible errors instead of silently returning blank images or "Error:" strings that downstream nodes tried to load BREAKING (behavioral only): failed API calls raise instead of returning blank/black images or error strings. Node signatures are unchanged.
80 lines
2.4 KiB
Python
80 lines
2.4 KiB
Python
"""Unit tests for fal error extraction and FalApiError formatting."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
|
|
class _FakeResponse:
|
|
def __init__(self, payload):
|
|
self._payload = payload
|
|
|
|
def json(self):
|
|
if isinstance(self._payload, Exception):
|
|
raise self._payload
|
|
return self._payload
|
|
|
|
|
|
class _FakeHTTPError(Exception):
|
|
"""Duck-typed stand-in for fal_client.FalClientHTTPError."""
|
|
|
|
def __init__(self, message, status_code, payload):
|
|
super().__init__(message)
|
|
self.status_code = status_code
|
|
self.response = _FakeResponse(payload)
|
|
|
|
|
|
def test_error_message_includes_model_and_status(errors_mod):
|
|
err = errors_mod.FalApiError("fal-ai/flux/dev", "boom", 422)
|
|
assert "fal-ai/flux/dev" in str(err)
|
|
assert "boom" in str(err)
|
|
assert "422" in str(err)
|
|
|
|
|
|
def test_extract_string_detail(errors_mod):
|
|
exc = _FakeHTTPError("HTTP 403", 403, {"detail": "Content policy violation"})
|
|
message, status = errors_mod.extract_error_message(exc)
|
|
assert message == "Content policy violation"
|
|
assert status == 403
|
|
|
|
|
|
def test_extract_validation_list(errors_mod):
|
|
exc = _FakeHTTPError(
|
|
"HTTP 422", 422,
|
|
{"detail": [
|
|
{"loc": ["body", "prompt"], "msg": "field required"},
|
|
{"loc": ["body", "seed"], "msg": "not an int"},
|
|
]},
|
|
)
|
|
message, status = errors_mod.extract_error_message(exc)
|
|
assert "prompt: field required" in message
|
|
assert "seed: not an int" in message
|
|
assert status == 422
|
|
|
|
|
|
def test_extract_falls_back_to_str(errors_mod):
|
|
message, status = errors_mod.extract_error_message(RuntimeError("plain failure"))
|
|
assert message == "plain failure"
|
|
assert status is None
|
|
|
|
|
|
def test_extract_survives_bad_response_json(errors_mod):
|
|
exc = _FakeHTTPError("HTTP 500", 500, ValueError("not json"))
|
|
message, status = errors_mod.extract_error_message(exc)
|
|
assert message # falls back to str(exc)
|
|
assert status == 500
|
|
|
|
|
|
def test_raise_fal_error_chains(errors_mod):
|
|
original = RuntimeError("root cause")
|
|
with pytest.raises(errors_mod.FalApiError) as excinfo:
|
|
errors_mod.raise_fal_error("some-model", original)
|
|
assert excinfo.value.__cause__ is original
|
|
|
|
|
|
def test_raise_fal_error_passthrough(errors_mod):
|
|
already = errors_mod.FalApiError("m", "msg")
|
|
with pytest.raises(errors_mod.FalApiError) as excinfo:
|
|
errors_mod.raise_fal_error("other", already)
|
|
assert excinfo.value is already
|