118 lines
5.0 KiB
Python
118 lines
5.0 KiB
Python
"""Run the same business workflow on interchangeable platforms, with failover.
|
|
|
|
Application code calls `FailoverRunner(["n8n", "miniflow"]).run("request", inputs)`.
|
|
Which platforms exist and how each one runs a workflow is decided by adapters
|
|
and `platforms.json`, so the calling code never changes when a platform is swapped.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from pathlib import Path
|
|
from typing import Any, Protocol
|
|
|
|
from miniflow.engine import WorkflowError, load_workflow, run_workflow
|
|
|
|
PROJECT_DIR = Path(__file__).resolve().parents[2]
|
|
CONFIG_FILE = PROJECT_DIR / "platforms.json"
|
|
|
|
|
|
class PlatformError(RuntimeError):
|
|
"""A platform could not run the workflow (down, timeout, bad answer)."""
|
|
|
|
|
|
class WorkflowPlatform(Protocol):
|
|
name: str
|
|
|
|
def run(self, workflow: str, inputs: dict[str, Any]) -> dict[str, Any]: ...
|
|
|
|
|
|
def load_config(path: Path = CONFIG_FILE) -> dict[str, Any]:
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
|
|
|
|
class N8nPlatform:
|
|
"""Calls an n8n workflow through its production webhook URL."""
|
|
|
|
name = "n8n"
|
|
|
|
def __init__(self, config: dict[str, Any]):
|
|
self.workflows = config["workflows"]
|
|
self.base_url = os.getenv("N8N_BASE_URL", config.get("n8n", {}).get("base_url", "http://localhost:5678"))
|
|
self.timeout = float(os.getenv("N8N_TIMEOUT", config.get("n8n", {}).get("timeout", 120)))
|
|
|
|
def run(self, workflow: str, inputs: dict[str, Any]) -> dict[str, Any]:
|
|
settings = self.workflows.get(workflow, {}).get("n8n")
|
|
if not settings:
|
|
raise PlatformError(f"n8n has no mapping for workflow '{workflow}'")
|
|
url = f"{self.base_url.rstrip('/')}/webhook/{settings['webhook']}"
|
|
request = urllib.request.Request(url, data=json.dumps(inputs).encode("utf-8"),
|
|
headers={"Content-Type": "application/json"}, method="POST")
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
|
return json.loads(response.read().decode("utf-8"))
|
|
except urllib.error.HTTPError as error:
|
|
raise PlatformError(f"n8n returned HTTP {error.code} for {url}") from error
|
|
except (urllib.error.URLError, TimeoutError, ConnectionError) as error:
|
|
raise PlatformError(f"n8n is not reachable at {url} ({error})") from error
|
|
except json.JSONDecodeError as error:
|
|
raise PlatformError("n8n answered with something that is not JSON") from error
|
|
|
|
|
|
class MiniflowPlatform:
|
|
"""Runs the matching JSON flow with the local miniflow engine."""
|
|
|
|
name = "miniflow"
|
|
|
|
def __init__(self, config: dict[str, Any]):
|
|
self.workflows = config["workflows"]
|
|
|
|
def run(self, workflow: str, inputs: dict[str, Any]) -> dict[str, Any]:
|
|
settings = self.workflows.get(workflow, {}).get("miniflow")
|
|
if not settings:
|
|
raise PlatformError(f"miniflow has no mapping for workflow '{workflow}'")
|
|
try:
|
|
report = run_workflow(load_workflow(PROJECT_DIR / settings["flow"]), inputs, verbose=False)
|
|
except WorkflowError as error:
|
|
raise PlatformError(str(error)) from error
|
|
if report["status"] != "completed":
|
|
failed = report["trace"][-1]
|
|
raise PlatformError(f"miniflow failed at node '{failed['id']}': {failed.get('error')}")
|
|
return report["result"]
|
|
|
|
|
|
PLATFORMS: dict[str, type] = {"n8n": N8nPlatform, "miniflow": MiniflowPlatform}
|
|
|
|
|
|
def register_platform(name: str, adapter: type) -> None:
|
|
"""Add another platform adapter without touching the calling code."""
|
|
PLATFORMS[name] = adapter
|
|
|
|
|
|
class FailoverRunner:
|
|
"""Try platforms in order; when one raises PlatformError, move on to the next."""
|
|
|
|
def __init__(self, order: list[str], config: dict[str, Any] | None = None):
|
|
config = config or load_config()
|
|
unknown = [name for name in order if name not in PLATFORMS]
|
|
if unknown:
|
|
raise PlatformError(f"Unknown platform(s): {', '.join(unknown)}. Registered: {', '.join(PLATFORMS)}")
|
|
self.platforms = [PLATFORMS[name](config) for name in order]
|
|
|
|
def run(self, workflow: str, inputs: dict[str, Any]) -> dict[str, Any]:
|
|
attempts = []
|
|
for platform in self.platforms:
|
|
started = time.perf_counter()
|
|
try:
|
|
result = platform.run(workflow, inputs)
|
|
except PlatformError as error:
|
|
attempts.append({"platform": platform.name, "status": "failed", "error": str(error),
|
|
"ms": round((time.perf_counter() - started) * 1000, 1)})
|
|
continue
|
|
attempts.append({"platform": platform.name, "status": "ok",
|
|
"ms": round((time.perf_counter() - started) * 1000, 1)})
|
|
return {"workflow": workflow, "served_by": platform.name, "result": result, "attempts": attempts}
|
|
raise PlatformError("All platforms failed: " + "; ".join(f"{a['platform']}: {a['error']}" for a in attempts))
|