Download scripts/keep_fal_warm.py from nenae18/leechard: direct link, hf CLI and curl.
- Browser
- Download file 8.02 kB
-
https://huggingface.co/spaces/nenae18/leechard/resolve/main/scripts/keep_fal_warm.py
- Command line
-
hf download hf://spaces/nenae18/leechard/scripts/keep_fal_warm.py
-
curl -L -o keep_fal_warm.py https://huggingface.co/spaces/nenae18/leechard/resolve/main/scripts/keep_fal_warm.py
8.02 kB
| """Keep the fal FLUX inpaint container WARM so customers never hit a cold start. | |
| fal serverless scales the FLUX container to zero when idle; the next request then | |
| pays ~2-3 min to spin it back up (cold). During salon hours this loop sends a small | |
| inpaint request every `interval` seconds so the container stays hot and real | |
| customer requests run warm (seconds-of-spin, not minutes). | |
| COST WARNING (learned the hard way): a ping is NOT free, and NOT cheap-per-pixel. | |
| fal flux-general bills $0.075 per MEGAPIXEL with an effective ~1 MP minimum, so even | |
| a 256px ping costs ~$0.075. At a 45s interval that is ~800 pings/10h β ~$60/day just | |
| to keep warm β far more than paying ~$0.075 per ACTUAL customer photo. Ping keep-warm | |
| is therefore NOT economical for this endpoint; prefer fal dedicated/keep_alive (if its | |
| fixed price beats your volume) or simply accept the occasional cold start + a queue/UX. | |
| This script is now mainly a diagnostic. | |
| CRITICAL TUNING: the interval must be SHORTER than fal's idle timeout, or the | |
| container scales down between pings and customers (and the pings themselves) still | |
| hit cold starts. We saw this live at 150s: pings alternated ~4s (warm) and | |
| 100-170s (cold) β i.e. fal's idle window is under 150s. This script now CLASSIFIES | |
| each ping (warm vs COLD) and warns when it's leaking, so you can dial the interval | |
| down empirically (try 90s, then 60s) until COLD stops appearing. | |
| Gated on FAL_KEY (this shell only; never logged/committed). Each ping bills ~$0.075 | |
| (see COST WARNING above) β shorter interval = more pings = much more cost. | |
| Stop with Ctrl+C. Pilot Ready: NOT CONFIRMED. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import sys | |
| import time | |
| from io import BytesIO | |
| from pathlib import Path | |
| sys.path.insert(0, str(Path(__file__).resolve().parents[1])) | |
| from app.services.fal_client import FalUnavailable, fal_inpaint_model, fal_real_enabled, run_fal_inpaint # noqa: E402 | |
| def classify(ok: bool, duration: float, warm_max: float) -> str: | |
| """Label a ping: 'warm' (fast = container was hot), 'COLD' (slow = it had to | |
| spin up, i.e. keep-warm is leaking), or 'FAIL' (errored/timed out).""" | |
| if not ok: | |
| return "FAIL" | |
| return "warm" if duration <= warm_max else "COLD" | |
| def should_warn_leak(n: int, label: str) -> bool: | |
| """True only when a ping signals a real between-ping leak. Ping #1 is the | |
| expected startup spin-up (container is down at launch) β never a leak; only | |
| a non-warm ping from #2 on means the container scaled down between pings.""" | |
| return n >= 2 and label != "warm" | |
| def sleep_seconds(interval: float, elapsed: float) -> float: | |
| """Fixed-cadence sleep: hold a CONSTANT start-to-start interval. Sleeping a flat | |
| `interval` AFTER each ping let a slow (cold ~28s) ping push the next gap out | |
| (60+28=88s), which kept tripping fal's idle window and cascaded more cold starts. | |
| Subtracting the elapsed ping time keeps the real gap == interval.""" | |
| return max(1.0, interval - elapsed) | |
| def is_fatal_error(msg: str) -> bool: | |
| """A NON-retryable fal failure (billing/auth) β keep-warm must STOP, not keep | |
| hammering a locked account every cadence with a misleading 'lower the interval' | |
| hint. Seen live: HTTP 403 'User is locked. Reason: Exhausted balance.'""" | |
| m = (msg or "").lower() | |
| return any(s in m for s in ( | |
| "exhausted balance", "user is locked", "http 403", "http 401", | |
| "unauthorized", "forbidden", "payment", "billing", | |
| )) | |
| def summary_line(counts: dict) -> str: | |
| """Rolling tally string. warm-rate = warm / total pings so far.""" | |
| total = sum(counts.values()) | |
| warm = counts.get("warm", 0) | |
| rate = (100.0 * warm / total) if total else 0.0 | |
| return (f"tally: {warm} warm / {counts.get('COLD', 0)} COLD / " | |
| f"{counts.get('FAIL', 0)} FAIL (warm-rate {rate:.0f}%)") | |
| def _tiny_png(size=256): | |
| from PIL import Image, ImageDraw | |
| img = Image.new("RGB", (size, size), (128, 128, 128)) | |
| mask = Image.new("L", (size, size), 0) | |
| ImageDraw.Draw(mask).ellipse([size * 0.3, size * 0.3, size * 0.7, size * 0.7], fill=255) | |
| a = BytesIO(); img.save(a, "PNG") | |
| b = BytesIO(); mask.save(b, "PNG") | |
| return a.getvalue(), b.getvalue() | |
| def main(argv: list[str] | None = None) -> int: | |
| ap = argparse.ArgumentParser(description="Keep the fal FLUX container warm") | |
| ap.add_argument("--interval", type=int, default=90, | |
| help="target seconds START-to-START (fixed cadence); MUST be under " | |
| "fal's idle timeout. 60s measured ~55%% warm -> try 45, then 30") | |
| ap.add_argument("--steps", type=int, default=4, help="cheap ping inference steps") | |
| ap.add_argument("--size", type=int, default=256, help="tiny ping image size") | |
| ap.add_argument("--warm-max", type=float, default=20.0, | |
| help="ping under this many seconds = warm; above = COLD (spin-up)") | |
| ap.add_argument("--timeout", type=int, default=240, | |
| help="per-ping timeout; must exceed a cold spin-up (~2-3 min)") | |
| args = ap.parse_args(argv) | |
| if not fal_real_enabled(): | |
| print("REFUSED: FAL_KEY not set (no pings sent).") | |
| return 2 | |
| img, mask = _tiny_png(args.size) | |
| print(f"keep-warm: model={fal_inpaint_model()} every {args.interval}s " | |
| f"(tiny {args.size}px, {args.steps} steps, warm<= {args.warm_max}s). Ctrl+C to stop.") | |
| counts = {"warm": 0, "COLD": 0, "FAIL": 0} | |
| n = 0 | |
| while True: | |
| n += 1 | |
| t = time.time() | |
| err_msg = "" | |
| try: | |
| run_fal_inpaint(img, mask, "warm", strength=0.2, num_inference_steps=args.steps, | |
| timeout_s=args.timeout) | |
| dur = time.time() - t | |
| label = classify(True, dur, args.warm_max) | |
| print(f" ping #{n}: {label} ({dur:.1f}s)") | |
| except FalUnavailable as exc: | |
| label = "FAIL"; err_msg = str(exc) | |
| print(f" ping #{n}: FAIL ({err_msg})") | |
| except Exception as exc: # noqa: BLE001 | |
| label = "FAIL"; err_msg = type(exc).__name__ | |
| print(f" ping #{n}: FAIL ({err_msg})") | |
| counts[label] += 1 | |
| # A billing/auth failure is NOT a warm-up problem β stop instead of hammering | |
| # a locked account (and stop the misleading "lower the interval" hint). | |
| if label == "FAIL" and is_fatal_error(err_msg): | |
| print(f" {summary_line(counts)}") | |
| print(" STOP: fatal fal error (billing/auth) β this is NOT a keep-warm " | |
| "issue. Fix the account (top up at fal.ai/dashboard/billing) or the " | |
| "key, then restart. Halting pings.") | |
| return 1 | |
| # ping #1 is ALWAYS the startup spin-up (the container is down when this | |
| # process starts) β expected cold, NOT an interval leak. From ping #2 on, | |
| # a non-warm ping means the container scaled down *between pings* -> the | |
| # interval is longer than fal's idle window. Warn only on that bad ping; | |
| # otherwise show the tally periodically. | |
| if should_warn_leak(n, label): | |
| print(f" {summary_line(counts)}") | |
| if label == "COLD": | |
| print(f" hint: COLD on ping #{n} (between-ping) -> container scaled " | |
| f"down; idle timeout < {args.interval}s; lower --interval " | |
| f"(try {max(30, args.interval - 15)}).") | |
| else: | |
| print(f" note: ping #{n} FAILED (transient/network) β retrying next cadence.") | |
| elif n == 1 and label != "warm": | |
| print(" (ping #1 = startup spin-up, expected cold; not a leak. " | |
| "Let it run and watch pings #2+ to judge the interval.)") | |
| elif label == "warm" and n % 10 == 0: | |
| print(f" {summary_line(counts)}") | |
| time.sleep(sleep_seconds(args.interval, time.time() - t)) | |
| if __name__ == "__main__": | |
| try: | |
| raise SystemExit(main()) | |
| except KeyboardInterrupt: | |
| print("\nkeep-warm: stopped.") | |