DrValera commited on
Commit
4062266
·
verified ·
1 Parent(s): 1642b0c

Demo routes for webpage added + call_limits + file_limits

Browse files
Files changed (2) hide show
  1. main.py +177 -24
  2. requirements.txt +6 -5
main.py CHANGED
@@ -1,11 +1,17 @@
1
- import os
2
- import requests
3
  from fastapi import FastAPI, Request, HTTPException, UploadFile, File, Form
4
- from fastapi.responses import JSONResponse, Response
 
 
 
 
 
5
 
6
  # --- Secrets set in the PUBLIC Space ---
7
- UPSTREAM_URL = os.environ.get("hf_url", "").rstrip("/") # e.g., https://datfid-org-datfid-api.hf.space
8
- HF_TOKEN = os.environ.get("hf_token") # HF access token to access your PRIVATE Space
 
 
9
 
10
  if not HF_TOKEN:
11
  raise RuntimeError("Missing secret 'hf_token' in public Space.")
@@ -14,6 +20,59 @@ if not UPSTREAM_URL.startswith("https://"):
14
 
15
  app = FastAPI(title="DATFID Public Proxy", docs_url="/docs", redoc_url=None)
16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  def _extract_user_token(req: Request) -> str | None:
18
  """
19
  Read user's DATFID token from Authorization: Bearer <dt+...>.
@@ -24,7 +83,7 @@ def _extract_user_token(req: Request) -> str | None:
24
  return None
25
  return auth.split(" ", 1)[1].strip()
26
 
27
- def _forward(path: str, method: str = "GET", json_body=None, user_token: str | None = None):
28
  """
29
  Forward request to the PRIVATE Space:
30
  - 'Authorization: Bearer <HF_TOKEN>' to pass HF private gate
@@ -38,7 +97,10 @@ def _forward(path: str, method: str = "GET", json_body=None, user_token: str | N
38
  if user_token:
39
  headers["X-API-Key"] = user_token
40
 
41
- r = requests.request(method, url, headers=headers, json=json_body, timeout=600)
 
 
 
42
  ct = r.headers.get("content-type", "")
43
 
44
  if "application/json" in ct:
@@ -49,7 +111,7 @@ def _forward(path: str, method: str = "GET", json_body=None, user_token: str | N
49
  # Fallback: return short text envelope if non-JSON
50
  return JSONResponse(status_code=r.status_code, content={"text": r.text[:1000]})
51
 
52
- def _forward_stream(path: str, files=None, data=None, user_token: str | None = None, method: str = "POST"):
53
  url = f"{UPSTREAM_URL}{path}"
54
  headers = {
55
  "Authorization": f"Bearer {HF_TOKEN}",
@@ -58,20 +120,41 @@ def _forward_stream(path: str, files=None, data=None, user_token: str | None = N
58
  if user_token:
59
  headers["X-API-Key"] = user_token
60
 
61
- r = requests.request(method, url, headers=headers, files=files, data=data, timeout=600)
62
- # Pass through status, content-type, and content-disposition if present
63
- media_type = r.headers.get("content-type", "application/octet-stream")
64
- cd = r.headers.get("content-disposition")
65
- resp = Response(content=r.content, status_code=r.status_code, media_type=media_type)
66
- if cd:
67
- resp.headers["Content-Disposition"] = cd
68
- return resp
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
 
70
  @app.get("/")
71
  async def root(req: Request):
72
  # Forward to private root (private gate still needs HF token)
73
  user_token = _extract_user_token(req) # optional here
74
- return _forward("/", "GET", user_token=user_token)
75
 
76
  @app.get("/secure-ping/")
77
  async def secure_ping(req: Request):
@@ -79,7 +162,7 @@ async def secure_ping(req: Request):
79
  user_token = _extract_user_token(req)
80
  if not user_token:
81
  raise HTTPException(status_code=401, detail="Missing Authorization Bearer token (dt+...)")
82
- return _forward("/secure-ping/", "GET", user_token=user_token)
83
 
84
  @app.post("/modelfit/")
85
  async def modelfit(req: Request):
@@ -87,7 +170,7 @@ async def modelfit(req: Request):
87
  if not user_token:
88
  raise HTTPException(status_code=401, detail="Missing Authorization Bearer token (dt+...)")
89
  body = await req.json()
90
- return _forward("/modelfit/", "POST", json_body=body, user_token=user_token)
91
 
92
  @app.post("/modelforecast/")
93
  async def modelforecast(req: Request):
@@ -95,7 +178,7 @@ async def modelforecast(req: Request):
95
  if not user_token:
96
  raise HTTPException(status_code=401, detail="Missing Authorization Bearer token (dt+...)")
97
  body = await req.json()
98
- return _forward("/modelforecast/", "POST", json_body=body, user_token=user_token)
99
 
100
  @app.post("/modelfit-file/")
101
  async def modelfit_file(
@@ -115,8 +198,12 @@ async def modelfit_file(
115
  if not user_token:
116
  raise HTTPException(status_code=401, detail="Missing Authorization Bearer token (dt+...)")
117
 
 
 
 
 
118
  files = {
119
- "file": (file.filename, await file.read(), file.content_type or "application/octet-stream"),
120
  }
121
  data = {
122
  "id_col": id_col,
@@ -128,7 +215,7 @@ async def modelfit_file(
128
  "filter_by_significance": filter_by_significance,
129
  "meanvar_test": meanvar_test,
130
  }
131
- return _forward_stream("/modelfit-file/", files=files, data=data, user_token=user_token, method="POST")
132
 
133
  @app.post("/modelforecast-file/")
134
  async def modelforecast_file(
@@ -138,8 +225,74 @@ async def modelforecast_file(
138
  user_token = _extract_user_token(req)
139
  if not user_token:
140
  raise HTTPException(status_code=401, detail="Missing Authorization Bearer token (dt+...)")
 
 
 
 
141
 
142
  files = {
143
- "df_forecast": (df_forecast.filename, await df_forecast.read(), df_forecast.content_type or "application/octet-stream"),
144
  }
145
- return _forward_stream("/modelforecast-file/", files=files, data=None, user_token=user_token, method="POST")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, httpx
 
2
  from fastapi import FastAPI, Request, HTTPException, UploadFile, File, Form
3
+ from fastapi.responses import JSONResponse, Response, StreamingResponse
4
+ from fastapi.middleware.cors import CORSMiddleware
5
+ from slowapi import Limiter
6
+ from slowapi.util import get_remote_address
7
+ from slowapi.errors import RateLimitExceeded
8
+ from slowapi.middleware import SlowAPIMiddleware
9
 
10
  # --- Secrets set in the PUBLIC Space ---
11
+ UPSTREAM_URL = os.environ.get("hf_url", "").rstrip("/") # url to access Private Space
12
+ HF_TOKEN = os.environ.get("hf_token") # HF access token to access Private Space
13
+ DEMO_FORWARD_URL = os.getenv("DEMO_FORWARD_URL", "").rstrip("/") # url to acces demo space
14
+ DATFID_DEMO_TOKEN = os.getenv("DATFID_DEMO_TOKEN", "") # token to access demo space
15
 
16
  if not HF_TOKEN:
17
  raise RuntimeError("Missing secret 'hf_token' in public Space.")
 
20
 
21
  app = FastAPI(title="DATFID Public Proxy", docs_url="/docs", redoc_url=None)
22
 
23
+ # Prefer X-Forwarded-For (HF sits behind a proxy)
24
+ def client_ip(request: Request):
25
+ xff = request.headers.get("x-forwarded-for")
26
+ return xff.split(",")[0].strip() if xff else (request.client.host or "0.0.0.0")
27
+
28
+ limiter = Limiter(key_func=client_ip) # or get_remote_address
29
+ app.state.limiter = limiter
30
+ app.add_middleware(SlowAPIMiddleware)
31
+
32
+ @app.exception_handler(RateLimitExceeded)
33
+ async def ratelimit_handler(request: Request, exc: RateLimitExceeded):
34
+ return JSONResponse(status_code=429, content={"detail": "Too many requests, slow down."})
35
+
36
+ SDK_MAX_BODY_BYTES = int(os.getenv("SDK_MAX_BODY_BYTES", "25000000")) # 25MB default for demo
37
+ SDK_MAX_BODY_BYTES_extended = int(os.getenv("SDK_MAX_BODY_BYTES_extended", "125000000")) # 125MB default for prod
38
+
39
+ # Early global body-size guard (runs before routes)
40
+ @app.middleware("http")
41
+ async def limit_body_size(request: Request, call_next):
42
+ cl = request.headers.get("content-length")
43
+
44
+ # demo routes use the smaller cap, others use extended
45
+ path = request.url.path or ""
46
+ cap = SDK_MAX_BODY_BYTES if "-demo" in path else SDK_MAX_BODY_BYTES_extended
47
+
48
+ if cl and int(cl) > cap:
49
+ return JSONResponse(
50
+ {"detail": f"Payload too large (> {cap} bytes)"},
51
+ status_code=413,
52
+ )
53
+ return await call_next(request)
54
+
55
+ # CORS for browser
56
+ app.add_middleware(
57
+ CORSMiddleware,
58
+ allow_origins=[
59
+ "https://datfid.com",
60
+ "https://www.datfid.com"
61
+ ],
62
+ # Optional: allow Vercel preview domains
63
+ # allow_origin_regex=r"^https:\/\/.*\.vercel\.app$",
64
+ allow_methods=["POST", "OPTIONS"],
65
+ allow_headers=["Content-Type"], # no Authorization header needed from browser
66
+ allow_credentials=False,
67
+ max_age=86400,
68
+ )
69
+
70
+ # to ensure we don’t leak hop-by-hop headers
71
+ def _filter_resp_headers(h):
72
+ # pass through useful headers but strip hop-by-hop
73
+ allowed = {"content-type", "content-disposition", "content-length"}
74
+ return {k: v for k, v in h.items() if k.lower() in allowed}
75
+
76
  def _extract_user_token(req: Request) -> str | None:
77
  """
78
  Read user's DATFID token from Authorization: Bearer <dt+...>.
 
83
  return None
84
  return auth.split(" ", 1)[1].strip()
85
 
86
+ async def _forward(path: str, method: str = "GET", json_body=None, user_token: str | None = None):
87
  """
88
  Forward request to the PRIVATE Space:
89
  - 'Authorization: Bearer <HF_TOKEN>' to pass HF private gate
 
97
  if user_token:
98
  headers["X-API-Key"] = user_token
99
 
100
+ timeout = httpx.Timeout(600.0)
101
+ async with httpx.AsyncClient(timeout=timeout) as client:
102
+ r = await client.request(method, url, headers=headers, json=json_body)
103
+
104
  ct = r.headers.get("content-type", "")
105
 
106
  if "application/json" in ct:
 
111
  # Fallback: return short text envelope if non-JSON
112
  return JSONResponse(status_code=r.status_code, content={"text": r.text[:1000]})
113
 
114
+ async def _forward_stream(path: str, files=None, data=None, user_token: str | None = None, method: str = "POST"):
115
  url = f"{UPSTREAM_URL}{path}"
116
  headers = {
117
  "Authorization": f"Bearer {HF_TOKEN}",
 
120
  if user_token:
121
  headers["X-API-Key"] = user_token
122
 
123
+ timeout = httpx.Timeout(600.0)
124
+ async with httpx.AsyncClient(timeout=timeout) as client:
125
+ async with client.stream(method, url, headers=headers, files=files, data=data) as resp:
126
+ if resp.status_code >= 400:
127
+ text = await resp.aread()
128
+ return Response(content=text, status_code=resp.status_code, media_type=resp.headers.get("content-type","text/plain"))
129
+ return StreamingResponse(resp.aiter_raw(), status_code=resp.status_code, headers=_filter_resp_headers(resp.headers))
130
+
131
+ # for demo
132
+ async def _forward_demo_stream(path: str, *, files: dict | None, data: dict | None, method: str = "POST"):
133
+ if not DEMO_FORWARD_URL or not HF_TOKEN or not DATFID_DEMO_TOKEN:
134
+ raise HTTPException(status_code=500, detail="Demo not configured.")
135
+
136
+ url = DEMO_FORWARD_URL + path
137
+ headers = {
138
+ # HF private-space gate:
139
+ "Authorization": f"Bearer {HF_TOKEN}",
140
+ # App-level demo token (checked by the private API):
141
+ "X-DATFID-Token": DATFID_DEMO_TOKEN,
142
+ }
143
+
144
+ timeout = httpx.Timeout(120.0)
145
+ async with httpx.AsyncClient(timeout=timeout) as client:
146
+ # stream response back to the caller (so large CSVs don’t load fully into memory)
147
+ async with client.stream(method, url, headers=headers, files=files, data=data) as resp:
148
+ if resp.status_code >= 400:
149
+ text = await resp.aread()
150
+ raise HTTPException(status_code=resp.status_code, detail=text.decode(errors="ignore"))
151
+ return StreamingResponse(resp.aiter_raw(), status_code=resp.status_code, headers=_filter_resp_headers(resp.headers))
152
 
153
  @app.get("/")
154
  async def root(req: Request):
155
  # Forward to private root (private gate still needs HF token)
156
  user_token = _extract_user_token(req) # optional here
157
+ return await _forward("/", "GET", user_token=user_token)
158
 
159
  @app.get("/secure-ping/")
160
  async def secure_ping(req: Request):
 
162
  user_token = _extract_user_token(req)
163
  if not user_token:
164
  raise HTTPException(status_code=401, detail="Missing Authorization Bearer token (dt+...)")
165
+ return await _forward("/secure-ping/", "GET", user_token=user_token)
166
 
167
  @app.post("/modelfit/")
168
  async def modelfit(req: Request):
 
170
  if not user_token:
171
  raise HTTPException(status_code=401, detail="Missing Authorization Bearer token (dt+...)")
172
  body = await req.json()
173
+ return await _forward("/modelfit/", "POST", json_body=body, user_token=user_token)
174
 
175
  @app.post("/modelforecast/")
176
  async def modelforecast(req: Request):
 
178
  if not user_token:
179
  raise HTTPException(status_code=401, detail="Missing Authorization Bearer token (dt+...)")
180
  body = await req.json()
181
+ return await _forward("/modelforecast/", "POST", json_body=body, user_token=user_token)
182
 
183
  @app.post("/modelfit-file/")
184
  async def modelfit_file(
 
198
  if not user_token:
199
  raise HTTPException(status_code=401, detail="Missing Authorization Bearer token (dt+...)")
200
 
201
+ raw = await file.read()
202
+ if len(raw) > SDK_MAX_BODY_BYTES_extended:
203
+ raise HTTPException(status_code=413, detail="Payload too large.")
204
+
205
  files = {
206
+ "file": (file.filename, raw, file.content_type or "application/octet-stream"),
207
  }
208
  data = {
209
  "id_col": id_col,
 
215
  "filter_by_significance": filter_by_significance,
216
  "meanvar_test": meanvar_test,
217
  }
218
+ return await _forward_stream("/modelfit-file/", files=files, data=data, user_token=user_token, method="POST")
219
 
220
  @app.post("/modelforecast-file/")
221
  async def modelforecast_file(
 
225
  user_token = _extract_user_token(req)
226
  if not user_token:
227
  raise HTTPException(status_code=401, detail="Missing Authorization Bearer token (dt+...)")
228
+
229
+ raw = await df_forecast.read()
230
+ if len(raw) > SDK_MAX_BODY_BYTES_extended:
231
+ raise HTTPException(status_code=413, detail="Payload too large.")
232
 
233
  files = {
234
+ "df_forecast": (df_forecast.filename, raw, df_forecast.content_type or "application/octet-stream"),
235
  }
236
+ return await _forward_stream("/modelforecast-file/", files=files, data=None, user_token=user_token, method="POST")
237
+
238
+ @limiter.limit("10/10minute") # 10 calls per 10 minutes per IP
239
+ @app.post("/modelfit-file-demo/")
240
+ async def modelfit_file_demo(
241
+ request: Request,
242
+ file: UploadFile = File(...),
243
+ id_col: str = Form(...),
244
+ time_col: str = Form(...),
245
+ y: str = Form(...),
246
+ # optional knobs
247
+ lag_y: str = Form(""),
248
+ lagged_features: str = Form(""),
249
+ current_features: str = Form(""),
250
+ filter_by_significance: str = Form("false"),
251
+ meanvar_test: str = Form("false"),
252
+ ):
253
+
254
+ # read once, size-guard it, then forward
255
+ raw = await file.read()
256
+ if len(raw) > SDK_MAX_BODY_BYTES:
257
+ raise HTTPException(status_code=413, detail="Payload too large.")
258
+
259
+ files = {
260
+ "file": (file.filename, raw, file.content_type or "application/octet-stream"),
261
+ }
262
+ data = {
263
+ "id_col": id_col,
264
+ "time_col": time_col,
265
+ "y": y,
266
+ "lag_y": lag_y,
267
+ "lagged_features": lagged_features,
268
+ "current_features": current_features,
269
+ "filter_by_significance": filter_by_significance,
270
+ "meanvar_test": meanvar_test,
271
+ }
272
+ # the path goes to the private demo route
273
+ return await _forward_demo_stream("/modelfit-file-demo/", files=files, data=data, method="POST")
274
+
275
+ @limiter.limit("10/10minute")
276
+ @app.post("/modelforecast-file-demo/")
277
+ async def modelforecast_file_demo(
278
+ request: Request,
279
+ df_forecast: UploadFile = File(...),
280
+ ):
281
+
282
+ raw = await df_forecast.read()
283
+ if len(raw) > SDK_MAX_BODY_BYTES:
284
+ raise HTTPException(status_code=413, detail="Payload too large.")
285
+
286
+ files = {
287
+ "df_forecast": (df_forecast.filename, raw, df_forecast.content_type or "application/octet-stream"),
288
+ }
289
+ return await _forward_demo_stream("/modelforecast-file-demo/", files=files, data=None, method="POST")
290
+
291
+ @app.get("/health-demo-proxy")
292
+ async def health_demo_proxy():
293
+ # convenience endpoint to test private-space + demo token hop
294
+ try:
295
+ return await _forward_demo_stream("/health-demo", files=None, data=None, method="GET")
296
+ except HTTPException as e:
297
+ # bubble up errors so you can diagnose missing tokens, wrong URL, etc.
298
+ raise e
requirements.txt CHANGED
@@ -1,5 +1,6 @@
1
- fastapi
2
- uvicorn
3
- requests
4
- python-multipart
5
- gunicorn
 
 
1
+ fastapi
2
+ uvicorn
3
+ httpx
4
+ python-multipart
5
+ slowapi
6
+ limits