victor HF Staff commited on
Commit
1d7f241
Β·
1 Parent(s): 1e6c032

Improve MCP server concurrency and safety

Browse files
Files changed (3) hide show
  1. analytics.py +7 -2
  2. app.py +286 -53
  3. requirements.txt +2 -2
analytics.py CHANGED
@@ -1,6 +1,7 @@
1
  # ─── analytics.py ──────────────────────────────────────────────────────────────
2
  import os
3
  import json
 
4
  from datetime import datetime, timedelta, timezone
5
  from filelock import FileLock # pip install filelock
6
  import pandas as pd # already available in HF images
@@ -63,8 +64,7 @@ def _normalize_counts_schema(data: dict) -> dict:
63
  # ──────────────────────────────────────────────────────────────────────────────
64
  # Public API
65
  # ──────────────────────────────────────────────────────────────────────────────
66
- async def record_request(tool: str) -> None:
67
- """Increment today's counter (UTC) for the given tool: 'search' or 'fetch'."""
68
  tool = (tool or "").strip().lower()
69
  if tool not in {"search", "fetch"}:
70
  # Ignore unknown tool buckets to keep charts clean
@@ -79,6 +79,11 @@ async def record_request(tool: str) -> None:
79
  _save_counts(data)
80
 
81
 
 
 
 
 
 
82
  def last_n_days_count_df(tool: str, n: int = 30) -> pd.DataFrame:
83
  """Return DataFrame with a row for each of the past n days for the given tool."""
84
  tool = (tool or "").strip().lower()
 
1
  # ─── analytics.py ──────────────────────────────────────────────────────────────
2
  import os
3
  import json
4
+ import asyncio
5
  from datetime import datetime, timedelta, timezone
6
  from filelock import FileLock # pip install filelock
7
  import pandas as pd # already available in HF images
 
64
  # ──────────────────────────────────────────────────────────────────────────────
65
  # Public API
66
  # ──────────────────────────────────────────────────────────────────────────────
67
+ def _record_request_sync(tool: str) -> None:
 
68
  tool = (tool or "").strip().lower()
69
  if tool not in {"search", "fetch"}:
70
  # Ignore unknown tool buckets to keep charts clean
 
79
  _save_counts(data)
80
 
81
 
82
+ async def record_request(tool: str) -> None:
83
+ """Increment today's counter (UTC) for the given tool: 'search' or 'fetch'."""
84
+ await asyncio.to_thread(_record_request_sync, tool)
85
+
86
+
87
  def last_n_days_count_df(tool: str, n: int = 30) -> pd.DataFrame:
88
  """Return DataFrame with a row for each of the past n days for the given tool."""
89
  tool = (tool or "").strip().lower()
app.py CHANGED
@@ -2,7 +2,10 @@ import os
2
  import time
3
  import re
4
  import html
5
- from typing import Optional, Dict, Any, List
 
 
 
6
  from urllib.parse import urlsplit
7
  from datetime import datetime, timezone
8
 
@@ -24,15 +27,179 @@ SERPER_SEARCH_ENDPOINT = "https://google.serper.dev/search"
24
  SERPER_NEWS_ENDPOINT = "https://google.serper.dev/news"
25
  HEADERS = {"X-API-KEY": SERPER_API_KEY or "", "Content-Type": "application/json"}
26
 
27
- # Rate limiting (shared by both tools)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  storage = MemoryStorage()
29
  limiter = MovingWindowRateLimiter(storage)
30
- rate_limit = parse("360/hour") # shared global limit across search + fetch
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
 
33
  # ──────────────────────────────────────────────────────────────────────────────
34
  # Helpers
35
  # ──────────────────────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  def _domain_from_url(url: str) -> str:
37
  try:
38
  netloc = urlsplit(url).netloc
@@ -62,37 +229,48 @@ def _extract_title_from_html(html_text: str) -> Optional[str]:
62
  # Tool: search (metadata only)
63
  # ──────────────────────────────────────────────────────────────────────────────
64
  async def search(
65
- query: str, search_type: str = "search", num_results: Optional[int] = 4
 
 
 
66
  ) -> Dict[str, Any]:
67
- """
68
- Perform a web or news search via Serper and return metadata ONLY.
69
- Does NOT fetch or extract content from result URLs.
70
- """
71
  start_time = time.time()
72
 
73
- # Validate inputs
74
  if not query or not query.strip():
75
  await record_request("search")
76
  return {"error": "Missing 'query'. Please provide a search query string."}
77
 
 
78
  if num_results is None:
79
  num_results = 4
80
- num_results = max(1, min(20, int(num_results)))
 
 
 
 
81
  if search_type not in ["search", "news"]:
82
  search_type = "search"
83
 
84
- # Check API key
85
  if not SERPER_API_KEY:
86
  await record_request("search")
87
  return {
88
  "error": "SERPER_API_KEY is not set. Export SERPER_API_KEY and try again."
89
  }
90
 
 
 
91
  try:
92
- # Rate limit
93
- if not await limiter.hit(rate_limit, "global"):
 
 
 
 
 
 
94
  await record_request("search")
95
- return {"error": "Rate limit exceeded. Limit: 360 requests/hour."}
96
 
97
  endpoint = (
98
  SERPER_NEWS_ENDPOINT if search_type == "news" else SERPER_SEARCH_ENDPOINT
@@ -102,8 +280,12 @@ async def search(
102
  payload["type"] = "news"
103
  payload["page"] = 1
104
 
105
- async with httpx.AsyncClient(timeout=15) as client:
106
- resp = await client.post(endpoint, headers=HEADERS, json=payload)
 
 
 
 
107
 
108
  if resp.status_code != 200:
109
  await record_request("search")
@@ -115,32 +297,22 @@ async def search(
115
  raw_results: List[Dict[str, Any]] = (
116
  data.get("news", []) if search_type == "news" else data.get("organic", [])
117
  )
118
- if not raw_results:
119
- await record_request("search")
120
- return {
121
- "query": query,
122
- "search_type": search_type,
123
- "count": 0,
124
- "results": [],
125
- "message": f"No {search_type} results found.",
126
- }
127
 
128
  formatted: List[Dict[str, Any]] = []
129
- for idx, r in enumerate(raw_results[:num_results], start=1):
130
- item = {
131
  "position": idx,
132
- "title": r.get("title"),
133
- "link": r.get("link"),
134
- "domain": _domain_from_url(r.get("link", "")),
135
- "snippet": r.get("snippet") or r.get("description"),
136
  }
137
  if search_type == "news":
138
- item["source"] = r.get("source")
139
- item["date"] = _iso_date_or_unknown(r.get("date"))
140
- formatted.append(item)
141
 
142
- await record_request("search")
143
- return {
144
  "query": query,
145
  "search_type": search_type,
146
  "count": len(formatted),
@@ -148,6 +320,14 @@ async def search(
148
  "duration_s": round(time.time() - start_time, 2),
149
  }
150
 
 
 
 
 
 
 
 
 
151
  except Exception as e:
152
  await record_request("search")
153
  return {"error": f"Search failed: {str(e)}"}
@@ -156,10 +336,12 @@ async def search(
156
  # ──────────────────────────────────────────────────────────────────────────────
157
  # Tool: fetch (single URL fetch + extraction)
158
  # ──────────────────────────────────────────────────────────────────────────────
159
- async def fetch(url: str, timeout: int = 20) -> Dict[str, Any]:
160
- """
161
- Fetch a single URL and extract the main readable content.
162
- """
 
 
163
  start_time = time.time()
164
 
165
  if not url or not isinstance(url, str):
@@ -170,26 +352,67 @@ async def fetch(url: str, timeout: int = 20) -> Dict[str, Any]:
170
  return {"error": "URL must start with http:// or https://."}
171
 
172
  try:
173
- # Rate limit
174
- if not await limiter.hit(rate_limit, "global"):
 
 
 
 
 
 
 
 
 
 
 
175
  await record_request("fetch")
176
- return {"error": "Rate limit exceeded. Limit: 360 requests/hour."}
177
 
178
- async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
179
- resp = await client.get(url)
 
 
 
180
 
181
- text = resp.text or ""
182
- content = (
183
- trafilatura.extract(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  text,
185
  include_formatting=False,
186
  include_comments=False,
187
  )
188
- or ""
189
- )
190
 
 
191
  title = _extract_title_from_html(text) or ""
192
- final_url_str = str(resp.url) if hasattr(resp, "url") else url
193
  domain = _domain_from_url(final_url_str)
194
  word_count = len(content.split()) if content else 0
195
 
@@ -197,14 +420,18 @@ async def fetch(url: str, timeout: int = 20) -> Dict[str, Any]:
197
  "url": url,
198
  "final_url": final_url_str,
199
  "domain": domain,
200
- "status_code": resp.status_code,
201
  "title": title,
202
  "fetched_at": datetime.now(timezone.utc).isoformat(),
203
  "word_count": word_count,
204
- "content": content.strip(),
205
  "duration_s": round(time.time() - start_time, 2),
206
  }
207
 
 
 
 
 
208
  await record_request("fetch")
209
  return result
210
 
@@ -366,6 +593,12 @@ with gr.Blocks(title="Web MCP Server") as demo:
366
  gr.api(fetch, api_name="fetch")
367
 
368
 
 
 
 
 
 
 
369
  if __name__ == "__main__":
370
  # Launch with MCP server enabled
371
  demo.launch(mcp_server=True, show_api=True)
 
2
  import time
3
  import re
4
  import html
5
+ import asyncio
6
+ import ipaddress
7
+ import socket
8
+ from typing import Optional, Dict, Any, List, Tuple
9
  from urllib.parse import urlsplit
10
  from datetime import datetime, timezone
11
 
 
27
  SERPER_NEWS_ENDPOINT = "https://google.serper.dev/news"
28
  HEADERS = {"X-API-KEY": SERPER_API_KEY or "", "Content-Type": "application/json"}
29
 
30
+ # HTTP clients with connection pooling
31
+ SERPER_TIMEOUT = httpx.Timeout(connect=5.0, read=10.0, write=5.0, pool=5.0)
32
+ WEB_TIMEOUT = httpx.Timeout(connect=5.0, read=20.0, write=5.0, pool=5.0)
33
+
34
+ SERPER_LIMITS = httpx.Limits(
35
+ max_keepalive_connections=int(os.getenv("SERPER_KEEPALIVE", "32")),
36
+ max_connections=int(os.getenv("SERPER_MAX_CONNECTIONS", "128")),
37
+ )
38
+ WEB_LIMITS = httpx.Limits(
39
+ max_keepalive_connections=int(os.getenv("WEB_KEEPALIVE", "128")),
40
+ max_connections=int(os.getenv("WEB_MAX_CONNECTIONS", "512")),
41
+ )
42
+
43
+ serper_client = httpx.AsyncClient(
44
+ timeout=SERPER_TIMEOUT,
45
+ limits=SERPER_LIMITS,
46
+ http2=True,
47
+ headers=HEADERS,
48
+ )
49
+
50
+ DEFAULT_USER_AGENT = os.getenv(
51
+ "FETCH_USER_AGENT",
52
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
53
+ "(KHTML, like Gecko) Chrome/124.0 Safari/537.36",
54
+ )
55
+
56
+ web_client = httpx.AsyncClient(
57
+ timeout=WEB_TIMEOUT,
58
+ limits=WEB_LIMITS,
59
+ http2=True,
60
+ follow_redirects=True,
61
+ headers={"User-Agent": DEFAULT_USER_AGENT},
62
+ )
63
+
64
+ # Rate limiting (shared by both tools, process-local)
65
+ GLOBAL_RATE = parse(os.getenv("GLOBAL_RATE", "3000/minute"))
66
+ PER_IP_RATE = parse(os.getenv("PER_IP_RATE", "60/minute"))
67
  storage = MemoryStorage()
68
  limiter = MovingWindowRateLimiter(storage)
69
+
70
+ # Concurrency controls & resource caps
71
+ FETCH_MAX_BYTES = max(1024, int(os.getenv("FETCH_MAX_BYTES", "1500000")))
72
+ FETCH_CONCURRENCY = max(1, int(os.getenv("FETCH_CONCURRENCY", "64")))
73
+ SEARCH_CONCURRENCY = max(1, int(os.getenv("SEARCH_CONCURRENCY", "64")))
74
+ EXTRACT_CONCURRENCY = max(
75
+ 1,
76
+ int(
77
+ os.getenv(
78
+ "EXTRACT_CONCURRENCY",
79
+ str(max(4, (os.cpu_count() or 2) * 2)),
80
+ )
81
+ ),
82
+ )
83
+
84
+ SEARCH_CACHE_TTL = max(0, int(os.getenv("SEARCH_CACHE_TTL", "30")))
85
+ FETCH_CACHE_TTL = max(0, int(os.getenv("FETCH_CACHE_TTL", "300")))
86
+
87
+ _search_cache: Dict[Tuple[str, str, int], Dict[str, Any]] = {}
88
+ _fetch_cache: Dict[str, Dict[str, Any]] = {}
89
+ _search_cache_lock: Optional[asyncio.Lock] = None
90
+ _fetch_cache_lock: Optional[asyncio.Lock] = None
91
+ _search_sema: Optional[asyncio.Semaphore] = None
92
+ _fetch_sema: Optional[asyncio.Semaphore] = None
93
+ _extract_sema: Optional[asyncio.Semaphore] = None
94
 
95
 
96
  # ──────────────────────────────────────────────────────────────────────────────
97
  # Helpers
98
  # ──────────────────────────────────────────────────────────────────────────────
99
+ def _get_cache_lock(name: str) -> asyncio.Lock:
100
+ global _search_cache_lock, _fetch_cache_lock
101
+ if name == "search":
102
+ if _search_cache_lock is None:
103
+ _search_cache_lock = asyncio.Lock()
104
+ return _search_cache_lock
105
+ if name == "fetch":
106
+ if _fetch_cache_lock is None:
107
+ _fetch_cache_lock = asyncio.Lock()
108
+ return _fetch_cache_lock
109
+ raise ValueError(f"Unknown cache lock: {name}")
110
+
111
+
112
+ def _get_semaphore(name: str) -> asyncio.Semaphore:
113
+ global _search_sema, _fetch_sema, _extract_sema
114
+ if name == "search":
115
+ if _search_sema is None:
116
+ _search_sema = asyncio.Semaphore(SEARCH_CONCURRENCY)
117
+ return _search_sema
118
+ if name == "fetch":
119
+ if _fetch_sema is None:
120
+ _fetch_sema = asyncio.Semaphore(FETCH_CONCURRENCY)
121
+ return _fetch_sema
122
+ if name == "extract":
123
+ if _extract_sema is None:
124
+ _extract_sema = asyncio.Semaphore(EXTRACT_CONCURRENCY)
125
+ return _extract_sema
126
+ raise ValueError(f"Unknown semaphore: {name}")
127
+
128
+
129
+ async def _cache_get(name: str, cache: Dict[Any, Any], key: Any):
130
+ lock = _get_cache_lock(name)
131
+ async with lock:
132
+ entry = cache.get(key)
133
+ if not entry:
134
+ return None
135
+ if time.time() > entry["expires_at"]:
136
+ cache.pop(key, None)
137
+ return None
138
+ return entry["value"]
139
+
140
+
141
+ async def _cache_set(name: str, cache: Dict[Any, Any], key: Any, value: Any, ttl: int):
142
+ if ttl <= 0:
143
+ return
144
+ lock = _get_cache_lock(name)
145
+ async with lock:
146
+ cache[key] = {"expires_at": time.time() + ttl, "value": value}
147
+
148
+
149
+ def _client_ip(request: Optional[gr.Request]) -> str:
150
+ try:
151
+ if request is None:
152
+ return "unknown"
153
+ headers = getattr(request, "headers", None) or {}
154
+ xff = headers.get("x-forwarded-for")
155
+ if xff:
156
+ return xff.split(",")[0].strip()
157
+ client = getattr(request, "client", None)
158
+ if client and getattr(client, "host", None):
159
+ return client.host
160
+ except Exception:
161
+ pass
162
+ return "unknown"
163
+
164
+
165
+ async def _host_is_public(host: str) -> bool:
166
+ if not host:
167
+ return False
168
+
169
+ def _resolve() -> List[str]:
170
+ try:
171
+ return list({ai[4][0] for ai in socket.getaddrinfo(host, None)})
172
+ except Exception:
173
+ return []
174
+
175
+ addresses = await asyncio.to_thread(_resolve)
176
+ if not addresses:
177
+ # If resolution fails we let the HTTP fetch decide.
178
+ return True
179
+
180
+ for addr in addresses:
181
+ ip_obj = ipaddress.ip_address(addr)
182
+ if (
183
+ ip_obj.is_private
184
+ or ip_obj.is_loopback
185
+ or ip_obj.is_link_local
186
+ or ip_obj.is_multicast
187
+ or ip_obj.is_reserved
188
+ or ip_obj.is_unspecified
189
+ ):
190
+ return False
191
+ return True
192
+
193
+
194
+ async def _check_rate_limits(bucket: str, ip: str) -> Optional[str]:
195
+ if not await limiter.hit(GLOBAL_RATE, "global"):
196
+ return f"Global rate limit exceeded. Limit: {GLOBAL_RATE}."
197
+ if ip != "unknown":
198
+ if not await limiter.hit(PER_IP_RATE, f"{bucket}:{ip}"):
199
+ return f"Per-IP rate limit exceeded. Limit: {PER_IP_RATE}."
200
+ return None
201
+
202
+
203
  def _domain_from_url(url: str) -> str:
204
  try:
205
  netloc = urlsplit(url).netloc
 
229
  # Tool: search (metadata only)
230
  # ──────────────────────────────────────────────────────────────────────────────
231
  async def search(
232
+ query: str,
233
+ search_type: str = "search",
234
+ num_results: Optional[int] = 4,
235
+ request: Optional[gr.Request] = None,
236
  ) -> Dict[str, Any]:
237
+ """Perform a web or news search via Serper and return metadata only."""
 
 
 
238
  start_time = time.time()
239
 
 
240
  if not query or not query.strip():
241
  await record_request("search")
242
  return {"error": "Missing 'query'. Please provide a search query string."}
243
 
244
+ query = query.strip()
245
  if num_results is None:
246
  num_results = 4
247
+ try:
248
+ num_results = max(1, min(20, int(num_results)))
249
+ except (TypeError, ValueError):
250
+ num_results = 4
251
+
252
  if search_type not in ["search", "news"]:
253
  search_type = "search"
254
 
 
255
  if not SERPER_API_KEY:
256
  await record_request("search")
257
  return {
258
  "error": "SERPER_API_KEY is not set. Export SERPER_API_KEY and try again."
259
  }
260
 
261
+ ip = _client_ip(request)
262
+
263
  try:
264
+ rl_message = await _check_rate_limits("search", ip)
265
+ if rl_message:
266
+ await record_request("search")
267
+ return {"error": rl_message}
268
+
269
+ cache_key = (query, search_type, num_results)
270
+ cached = await _cache_get("search", _search_cache, cache_key)
271
+ if cached:
272
  await record_request("search")
273
+ return cached
274
 
275
  endpoint = (
276
  SERPER_NEWS_ENDPOINT if search_type == "news" else SERPER_SEARCH_ENDPOINT
 
280
  payload["type"] = "news"
281
  payload["page"] = 1
282
 
283
+ semaphore = _get_semaphore("search")
284
+ await semaphore.acquire()
285
+ try:
286
+ resp = await serper_client.post(endpoint, json=payload)
287
+ finally:
288
+ semaphore.release()
289
 
290
  if resp.status_code != 200:
291
  await record_request("search")
 
297
  raw_results: List[Dict[str, Any]] = (
298
  data.get("news", []) if search_type == "news" else data.get("organic", [])
299
  )
 
 
 
 
 
 
 
 
 
300
 
301
  formatted: List[Dict[str, Any]] = []
302
+ for idx, item in enumerate(raw_results[:num_results], start=1):
303
+ entry = {
304
  "position": idx,
305
+ "title": item.get("title"),
306
+ "link": item.get("link"),
307
+ "domain": _domain_from_url(item.get("link", "")),
308
+ "snippet": item.get("snippet") or item.get("description"),
309
  }
310
  if search_type == "news":
311
+ entry["source"] = item.get("source")
312
+ entry["date"] = _iso_date_or_unknown(item.get("date"))
313
+ formatted.append(entry)
314
 
315
+ result = {
 
316
  "query": query,
317
  "search_type": search_type,
318
  "count": len(formatted),
 
320
  "duration_s": round(time.time() - start_time, 2),
321
  }
322
 
323
+ if not formatted:
324
+ result["message"] = f"No {search_type} results found."
325
+
326
+ await _cache_set("search", _search_cache, cache_key, result, SEARCH_CACHE_TTL)
327
+ await record_request("search")
328
+
329
+ return result
330
+
331
  except Exception as e:
332
  await record_request("search")
333
  return {"error": f"Search failed: {str(e)}"}
 
336
  # ──────────────────────────────────────────────────────────────────────────────
337
  # Tool: fetch (single URL fetch + extraction)
338
  # ──────────────────────────────────────────────────────────────────────────────
339
+ async def fetch(
340
+ url: str,
341
+ timeout: int = 20,
342
+ request: Optional[gr.Request] = None,
343
+ ) -> Dict[str, Any]:
344
+ """Fetch a single URL and extract the main readable content."""
345
  start_time = time.time()
346
 
347
  if not url or not isinstance(url, str):
 
352
  return {"error": "URL must start with http:// or https://."}
353
 
354
  try:
355
+ timeout = max(5, min(60, int(timeout)))
356
+ except (TypeError, ValueError):
357
+ timeout = 20
358
+
359
+ ip = _client_ip(request)
360
+
361
+ try:
362
+ host = urlsplit(url).hostname or ""
363
+ if not host:
364
+ await record_request("fetch")
365
+ return {"error": "Invalid URL; unable to determine host."}
366
+ rl_message = await _check_rate_limits("fetch", ip)
367
+ if rl_message:
368
  await record_request("fetch")
369
+ return {"error": rl_message}
370
 
371
+ cache_key = (url, timeout)
372
+ cached = await _cache_get("fetch", _fetch_cache, cache_key)
373
+ if cached:
374
+ await record_request("fetch")
375
+ return cached
376
 
377
+ if not await _host_is_public(host):
378
+ await record_request("fetch")
379
+ return {"error": "Refusing to fetch private or local addresses."}
380
+
381
+ fetch_sema = _get_semaphore("fetch")
382
+ await fetch_sema.acquire()
383
+ try:
384
+ async with web_client.stream("GET", url, timeout=timeout) as resp:
385
+ status_code = resp.status_code
386
+ total = 0
387
+ chunks: List[bytes] = []
388
+ async for chunk in resp.aiter_bytes():
389
+ total += len(chunk)
390
+ if total > FETCH_MAX_BYTES:
391
+ break
392
+ chunks.append(chunk)
393
+ body = b"".join(chunks)
394
+ final_url_str = str(resp.url)
395
+ encoding = resp.encoding or "utf-8"
396
+ finally:
397
+ fetch_sema.release()
398
+
399
+ truncated = total > FETCH_MAX_BYTES
400
+ text = body.decode(encoding, errors="ignore")
401
+
402
+ extract_sema = _get_semaphore("extract")
403
+ await extract_sema.acquire()
404
+ try:
405
+ content = await asyncio.to_thread(
406
+ trafilatura.extract,
407
  text,
408
  include_formatting=False,
409
  include_comments=False,
410
  )
411
+ finally:
412
+ extract_sema.release()
413
 
414
+ content = (content or "").strip()
415
  title = _extract_title_from_html(text) or ""
 
416
  domain = _domain_from_url(final_url_str)
417
  word_count = len(content.split()) if content else 0
418
 
 
420
  "url": url,
421
  "final_url": final_url_str,
422
  "domain": domain,
423
+ "status_code": status_code,
424
  "title": title,
425
  "fetched_at": datetime.now(timezone.utc).isoformat(),
426
  "word_count": word_count,
427
+ "content": content,
428
  "duration_s": round(time.time() - start_time, 2),
429
  }
430
 
431
+ if truncated:
432
+ result["truncated"] = True
433
+
434
+ await _cache_set("fetch", _fetch_cache, cache_key, result, FETCH_CACHE_TTL)
435
  await record_request("fetch")
436
  return result
437
 
 
593
  gr.api(fetch, api_name="fetch")
594
 
595
 
596
+ demo.queue(
597
+ max_size=int(os.getenv("GRADIO_MAX_QUEUE", "256")),
598
+ default_concurrency_limit=int(os.getenv("GRADIO_CONCURRENCY", "32")),
599
+ )
600
+
601
+
602
  if __name__ == "__main__":
603
  # Launch with MCP server enabled
604
  demo.launch(mcp_server=True, show_api=True)
requirements.txt CHANGED
@@ -1,6 +1,6 @@
1
  gradio
2
- httpx
3
  trafilatura
4
  python-dateutil
5
  limits
6
- filelock
 
1
  gradio
2
+ httpx[http2]
3
  trafilatura
4
  python-dateutil
5
  limits
6
+ filelock