Spaces:
Sleeping
Sleeping
| import os, asyncio, httpx | |
| from fastapi import FastAPI, Request, HTTPException, UploadFile, File, Form | |
| from fastapi.responses import JSONResponse, Response, StreamingResponse | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from slowapi import Limiter | |
| from slowapi.errors import RateLimitExceeded | |
| from slowapi.middleware import SlowAPIMiddleware | |
| # --- Public config --- | |
| # Hardcoded demo upstream URL (no secret/env needed). | |
| UPSTREAM_URL = "https://vadashuk-datfid-api.hf.space" | |
| DEMO_FORWARD_URL = os.getenv("DEMO_FORWARD_URL", "").rstrip("/") # optional demo-only backend (unused in hackathon flow) | |
| # This Space's public URL (used to ping self while waiting so HF does not put this Space to sleep). Override with SELF_URL env if different. | |
| SELF_URL = os.getenv("SELF_URL", "https://vadashuk-datfid-master.hf.space").rstrip("/") | |
| if not UPSTREAM_URL.startswith("https://"): | |
| raise RuntimeError("Invalid upstream URL in code.") | |
| if ".hf.space" not in UPSTREAM_URL: | |
| raise RuntimeError( | |
| "Invalid upstream URL. Use a live runtime URL like https://<name>.hf.space." | |
| ) | |
| app = FastAPI(title="DATFID Public Proxy", docs_url="/docs", redoc_url=None) | |
| # Prefer X-Forwarded-For (HF sits behind a proxy) | |
| def client_ip(request: Request): | |
| xff = request.headers.get("x-forwarded-for") | |
| return xff.split(",")[0].strip() if xff else (request.client.host or "0.0.0.0") | |
| limiter = Limiter(key_func=client_ip) # or get_remote_address | |
| GLOBAL_LIMIT = limiter.limit("100/10minute", key_func=lambda: "global:any") # global limit | |
| app.state.limiter = limiter | |
| app.add_middleware(SlowAPIMiddleware) | |
| async def ratelimit_handler(request: Request, exc: RateLimitExceeded): | |
| return JSONResponse(status_code=429, content={"detail": "Too many requests, slow down."}) | |
| SDK_MAX_BODY_BYTES = int(os.getenv("SDK_MAX_BODY_BYTES", "25000000")) # 25MB default for demo | |
| SDK_MAX_BODY_BYTES_extended = int(os.getenv("SDK_MAX_BODY_BYTES_extended", "125000000")) # 125MB default for prod | |
| # How long to wait for upstream (API) response; long runs may need 30+ min (1800+) | |
| UPSTREAM_TIMEOUT = float(os.getenv("UPSTREAM_TIMEOUT", "900")) # 15 minutes | |
| # While waiting for a response: every PING_INTERVAL seconds we ping the backend (secure_ping) and, if SELF_URL is set, we also ping ourselves (keep-alive) so this Space stays awake | |
| PING_INTERVAL = float(os.getenv("PING_INTERVAL", "270")) | |
| # Early global body-size guard (runs before routes) | |
| async def limit_body_size(request: Request, call_next): | |
| cl = request.headers.get("content-length") | |
| # demo routes use the smaller cap, others use extended | |
| path = request.url.path or "" | |
| cap = SDK_MAX_BODY_BYTES if "-demo" in path else SDK_MAX_BODY_BYTES_extended | |
| if cl and int(cl) > cap: | |
| return JSONResponse( | |
| {"detail": f"Payload too large (> {cap} bytes)"}, | |
| status_code=413, | |
| ) | |
| return await call_next(request) | |
| # CORS for browser | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=[ | |
| "https://datfid.com", | |
| "https://www.datfid.com" | |
| ], | |
| # Optional: allow Vercel preview domains | |
| # allow_origin_regex=r"^https:\/\/.*\.vercel\.app$", | |
| allow_methods=["POST", "OPTIONS"], | |
| allow_headers=["Content-Type"], | |
| allow_credentials=False, | |
| max_age=86400, | |
| ) | |
| # to ensure we don’t leak hop-by-hop headers | |
| def _filter_resp_headers(headers: dict) -> dict: | |
| # pass through useful headers but strip hop-by-hop | |
| allowed = {"content-disposition"} | |
| return {k: v for k, v in headers.items() if k.lower() in allowed} | |
| async def _ping_upstream_loop(ping_url: str, headers: dict, interval: float, self_url: str = ""): | |
| """Every `interval` seconds: if self_url set, GET self_url/keep-alive (keep this Space awake), then GET ping_url (backend). Stops when cancelled.""" | |
| if interval <= 0: | |
| return | |
| while True: | |
| await asyncio.sleep(interval) | |
| try: | |
| async with httpx.AsyncClient(timeout=10.0) as client: | |
| if self_url: | |
| try: | |
| await client.get(f"{self_url}/keep-alive") | |
| except Exception: | |
| pass | |
| await client.get(ping_url, headers=headers) | |
| except asyncio.CancelledError: | |
| break | |
| except Exception: | |
| pass | |
| def _start_ping_task(ping_url: str, headers: dict, self_url: str = ""): | |
| """Start background ping task: every PING_INTERVAL seconds ping backend and, if self_url set, also ping self.""" | |
| if PING_INTERVAL <= 0: | |
| return None | |
| return asyncio.create_task(_ping_upstream_loop(ping_url, headers, PING_INTERVAL, self_url)) | |
| async def _cancel_ping_task(task: asyncio.Task | None): | |
| if task is None: | |
| return | |
| task.cancel() | |
| try: | |
| await task | |
| except asyncio.CancelledError: | |
| pass | |
| async def _forward(path: str, method: str = "GET", json_body=None): | |
| """ | |
| Forward request to upstream API (public demo mode). | |
| While waiting, every PING_INTERVAL seconds: secure_ping to backend and, | |
| if SELF_URL set, ping self (keep-alive). | |
| """ | |
| url = f"{UPSTREAM_URL}{path}" | |
| headers = {"Accept": "application/json"} | |
| # While waiting: ping backend (secure_ping) and, if SELF_URL set, ping self so this Space stays awake | |
| ping_url = f"{UPSTREAM_URL}/secure-ping/" | |
| ping_headers = {} | |
| timeout = httpx.Timeout(UPSTREAM_TIMEOUT) | |
| r = None | |
| async def do_request(): | |
| nonlocal r | |
| async with httpx.AsyncClient(timeout=timeout) as client: | |
| r = await client.request(method, url, headers=headers, json=json_body) | |
| request_task = asyncio.create_task(do_request()) | |
| wait_sec = PING_INTERVAL if PING_INTERVAL > 0 else 0.0 | |
| try: | |
| while not request_task.done(): | |
| if wait_sec <= 0: | |
| await request_task | |
| break | |
| ping_sleep = asyncio.create_task(asyncio.sleep(wait_sec)) | |
| done, pending = await asyncio.wait( | |
| {request_task, ping_sleep}, | |
| return_when=asyncio.FIRST_COMPLETED, | |
| timeout=UPSTREAM_TIMEOUT + 10, | |
| ) | |
| for t in pending: | |
| if t is not request_task: | |
| t.cancel() | |
| try: | |
| await t | |
| except asyncio.CancelledError: | |
| pass | |
| if request_task in done: | |
| break | |
| async with httpx.AsyncClient(timeout=10.0) as c: | |
| if SELF_URL: | |
| try: | |
| await c.get(f"{SELF_URL}/keep-alive") | |
| except Exception: | |
| pass | |
| try: | |
| await c.get(ping_url, headers=ping_headers) | |
| except Exception: | |
| pass | |
| if not request_task.done(): | |
| request_task.cancel() | |
| try: | |
| await request_task | |
| except asyncio.CancelledError: | |
| pass | |
| await request_task | |
| except asyncio.CancelledError: | |
| request_task.cancel() | |
| try: | |
| await request_task | |
| except asyncio.CancelledError: | |
| pass | |
| raise | |
| exc = request_task.exception() | |
| if exc is not None: | |
| raise exc | |
| if r is None: | |
| raise RuntimeError("Upstream request did not complete") | |
| ct = r.headers.get("content-type", "") | |
| if "application/json" in ct: | |
| try: | |
| return JSONResponse(status_code=r.status_code, content=r.json()) | |
| except Exception: | |
| return JSONResponse(status_code=r.status_code, content={"error": r.text[:500]}) | |
| # Fallback: return short text envelope if non-JSON | |
| return JSONResponse(status_code=r.status_code, content={"text": r.text[:1000]}) | |
| async def _forward_stream(path: str, files=None, data=None, method: str = "POST"): | |
| url = f"{UPSTREAM_URL}{path}" | |
| headers = {"Accept": "*/*"} | |
| ping_headers = {} | |
| ping_task = _start_ping_task(f"{UPSTREAM_URL}/secure-ping/", ping_headers, SELF_URL) | |
| timeout = httpx.Timeout(UPSTREAM_TIMEOUT) | |
| client = httpx.AsyncClient(timeout=timeout, follow_redirects=True) | |
| # Don't open the context yet; the iterator must own the context lifetime. | |
| stream_ctx = client.stream(method, url, headers=headers, files=files, data=data) | |
| # Mutable holders we can fill once the stream opens | |
| status_holder = {"code": 200} | |
| media_type_holder = {"ct": "application/octet-stream"} | |
| headers_holder = {} | |
| async def body_iter(): | |
| chunk_queue: asyncio.Queue = asyncio.Queue() | |
| stream_done = {"done": False, "exc": None} | |
| async def stream_reader(): | |
| try: | |
| async with stream_ctx as resp: | |
| status_holder["code"] = resp.status_code | |
| media_type_holder["ct"] = resp.headers.get("content-type", "application/octet-stream") | |
| headers_holder.update(_filter_resp_headers(resp.headers)) | |
| if resp.status_code >= 400: | |
| chunk = await resp.aread() | |
| await chunk_queue.put(chunk) | |
| await chunk_queue.put(None) | |
| return | |
| async for chunk in resp.aiter_raw(): | |
| await chunk_queue.put(chunk) | |
| await chunk_queue.put(None) | |
| except Exception as e: | |
| stream_done["exc"] = e | |
| await chunk_queue.put(None) | |
| finally: | |
| stream_done["done"] = True | |
| reader_task = asyncio.create_task(stream_reader()) | |
| try: | |
| while True: | |
| chunk = await chunk_queue.get() | |
| if chunk is None: | |
| if stream_done["exc"]: | |
| raise stream_done["exc"] | |
| break | |
| yield chunk | |
| finally: | |
| reader_task.cancel() | |
| try: | |
| await reader_task | |
| except asyncio.CancelledError: | |
| pass | |
| await _cancel_ping_task(ping_task) | |
| await client.aclose() | |
| response = StreamingResponse( | |
| body_iter(), | |
| status_code=status_holder["code"], | |
| media_type=media_type_holder["ct"], | |
| headers=headers_holder, | |
| ) | |
| return response | |
| async def _forward_multipart_json(path: str, files=None, data=None, method: str = "POST"): | |
| """POST multipart to upstream and return JSON. While waiting: secure_ping to backend; if SELF_URL set, ping self.""" | |
| url = f"{UPSTREAM_URL}{path}" | |
| headers = {"Accept": "application/json"} | |
| ping_url = f"{UPSTREAM_URL}/secure-ping/" | |
| ping_headers = {} | |
| timeout = httpx.Timeout(UPSTREAM_TIMEOUT) | |
| r = None | |
| async def do_request(): | |
| nonlocal r | |
| async with httpx.AsyncClient(timeout=timeout) as client: | |
| r = await client.request(method, url, headers=headers, files=files, data=data) | |
| request_task = asyncio.create_task(do_request()) | |
| wait_sec = PING_INTERVAL if PING_INTERVAL > 0 else 0.0 | |
| try: | |
| while not request_task.done(): | |
| if wait_sec <= 0: | |
| await request_task | |
| break | |
| ping_sleep = asyncio.create_task(asyncio.sleep(wait_sec)) | |
| done, pending = await asyncio.wait( | |
| {request_task, ping_sleep}, | |
| return_when=asyncio.FIRST_COMPLETED, | |
| timeout=UPSTREAM_TIMEOUT + 10, | |
| ) | |
| for t in pending: | |
| if t is not request_task: | |
| t.cancel() | |
| try: | |
| await t | |
| except asyncio.CancelledError: | |
| pass | |
| if request_task in done: | |
| break | |
| async with httpx.AsyncClient(timeout=10.0) as c: | |
| if SELF_URL: | |
| try: | |
| await c.get(f"{SELF_URL}/keep-alive") | |
| except Exception: | |
| pass | |
| try: | |
| await c.get(ping_url, headers=ping_headers) | |
| except Exception: | |
| pass | |
| if not request_task.done(): | |
| request_task.cancel() | |
| try: | |
| await request_task | |
| except asyncio.CancelledError: | |
| pass | |
| await request_task | |
| except asyncio.CancelledError: | |
| request_task.cancel() | |
| try: | |
| await request_task | |
| except asyncio.CancelledError: | |
| pass | |
| raise | |
| exc = request_task.exception() | |
| if exc is not None: | |
| raise exc | |
| if r is None: | |
| raise RuntimeError("Upstream request did not complete") | |
| ct = r.headers.get("content-type", "") | |
| if "application/json" in ct: | |
| try: | |
| return JSONResponse(status_code=r.status_code, content=r.json()) | |
| except Exception: | |
| return JSONResponse(status_code=r.status_code, content={"error": r.text[:500]}) | |
| return JSONResponse(status_code=r.status_code, content={"text": r.text[:1000]}) | |
| # for demo | |
| async def _forward_demo_stream(path: str, *, files: dict | None, data: dict | None, method: str = "POST"): | |
| if not DEMO_FORWARD_URL: | |
| raise HTTPException(status_code=500, detail="Demo not configured.") | |
| url = DEMO_FORWARD_URL + path | |
| headers = {"Accept": "*/*"} | |
| ping_headers = {} | |
| ping_task = _start_ping_task(f"{DEMO_FORWARD_URL.rstrip('/')}/", ping_headers) | |
| timeout = httpx.Timeout(120.0) | |
| client = httpx.AsyncClient(timeout=timeout, follow_redirects=True) | |
| stream_ctx = client.stream(method, url, headers=headers, files=files, data=data) | |
| status_holder = {"code": 200} | |
| media_type_holder = {"ct": "application/octet-stream"} | |
| headers_holder = {} | |
| async def body_iter(): | |
| chunk_queue: asyncio.Queue = asyncio.Queue() | |
| stream_done = {"done": False, "exc": None} | |
| async def stream_reader(): | |
| try: | |
| async with stream_ctx as resp: | |
| status_holder["code"] = resp.status_code | |
| media_type_holder["ct"] = resp.headers.get("content-type", "application/octet-stream") | |
| headers_holder.update(_filter_resp_headers(resp.headers)) | |
| if resp.status_code >= 400: | |
| chunk = await resp.aread() | |
| await chunk_queue.put(chunk) | |
| await chunk_queue.put(None) | |
| return | |
| async for chunk in resp.aiter_raw(): | |
| await chunk_queue.put(chunk) | |
| await chunk_queue.put(None) | |
| except Exception as e: | |
| stream_done["exc"] = e | |
| await chunk_queue.put(None) | |
| finally: | |
| stream_done["done"] = True | |
| reader_task = asyncio.create_task(stream_reader()) | |
| try: | |
| while True: | |
| chunk = await chunk_queue.get() | |
| if chunk is None: | |
| if stream_done["exc"]: | |
| raise stream_done["exc"] | |
| break | |
| yield chunk | |
| finally: | |
| reader_task.cancel() | |
| try: | |
| await reader_task | |
| except asyncio.CancelledError: | |
| pass | |
| await _cancel_ping_task(ping_task) | |
| await client.aclose() | |
| response = StreamingResponse( | |
| body_iter(), | |
| status_code=status_holder["code"], | |
| media_type=media_type_holder["ct"], | |
| headers=headers_holder, | |
| ) | |
| return response | |
| async def root(req: Request): | |
| try: | |
| async with httpx.AsyncClient(timeout=10.0) as client: | |
| ping = await client.get(f"{UPSTREAM_URL}/") | |
| return { | |
| "message": "DATFID master demo is alive.", | |
| "upstream_url": UPSTREAM_URL, | |
| "upstream_status": ping.status_code, | |
| "upstream_ok": ping.status_code < 400, | |
| } | |
| except Exception as e: | |
| return JSONResponse( | |
| status_code=502, | |
| content={ | |
| "message": "DATFID master demo is alive, but upstream is unreachable.", | |
| "upstream_url": UPSTREAM_URL, | |
| "error": str(e)[:200], | |
| }, | |
| ) | |
| async def keep_alive(): | |
| """ | |
| Hit this to keep this Space (datfid_master) and the backend awake. | |
| - External cron (e.g. every 4 min): UptimeRobot, cron-job.org — keeps both spaces awake when idle. | |
| - During a long request (>5 min): the client can call this in a background thread every ~4 min | |
| so HF does not put this Space to sleep while it is waiting for the backend (no response | |
| sent to the client yet). This endpoint also pings the upstream (backend). No auth required. | |
| """ | |
| try: | |
| url = f"{UPSTREAM_URL}/" | |
| async with httpx.AsyncClient(timeout=10.0) as client: | |
| r = await client.get(url) | |
| return JSONResponse(content={"ok": True, "upstream_status": r.status_code}) | |
| except Exception as e: | |
| return JSONResponse(status_code=502, content={"ok": False, "error": str(e)[:200]}) | |
| async def secure_ping(req: Request): | |
| return await _forward("/secure-ping/", "GET") | |
| async def modelfit(req: Request): | |
| body = await req.json() | |
| return await _forward("/modelfit/", "POST", json_body=body) | |
| async def modelforecast(req: Request): | |
| body = await req.json() | |
| return await _forward("/modelforecast/", "POST", json_body=body) | |
| async def modelfit_file( | |
| req: Request, | |
| file: UploadFile = File(...), | |
| id_col: str = Form(...), | |
| time_col: str = Form(...), | |
| y: str = Form(...), | |
| # optional knobs | |
| lag_y: str = Form(""), | |
| lagged_features: str = Form(""), # JSON string or empty | |
| current_features: str = Form(""), # "all" | JSON string | "" | |
| filter_by_significance: str = Form("false"), | |
| meanvar_test: str = Form("false"), | |
| signif: str = Form("0.05"), | |
| ): | |
| raw = await file.read() | |
| if len(raw) > SDK_MAX_BODY_BYTES_extended: | |
| raise HTTPException(status_code=413, detail="Payload too large.") | |
| files = { | |
| "file": (file.filename, raw, file.content_type or "application/octet-stream"), | |
| } | |
| data = { | |
| "id_col": id_col, | |
| "time_col": time_col, | |
| "y": y, | |
| "lag_y": lag_y, | |
| "lagged_features": lagged_features, | |
| "current_features": current_features, | |
| "filter_by_significance": filter_by_significance, | |
| "meanvar_test": meanvar_test, | |
| "signif": signif, | |
| } | |
| return await _forward_stream("/modelfit-file/", files=files, data=data, method="POST") | |
| async def modelforecast_file( | |
| req: Request, | |
| df_forecast: UploadFile = File(...), | |
| ): | |
| raw = await df_forecast.read() | |
| if len(raw) > SDK_MAX_BODY_BYTES_extended: | |
| raise HTTPException(status_code=413, detail="Payload too large.") | |
| files = { | |
| "df_forecast": (df_forecast.filename, raw, df_forecast.content_type or "application/octet-stream"), | |
| } | |
| return await _forward_stream("/modelforecast-file/", files=files, data=None, method="POST") | |
| async def health_demo_proxy(): | |
| # convenience endpoint to test demo proxy hop | |
| try: | |
| return await _forward_demo_stream("/health-demo", files=None, data=None, method="GET") | |
| except HTTPException as e: | |
| # bubble up errors so you can diagnose missing config, wrong URL, etc. | |
| raise e | |