Files

154 lines
8.3 KiB
Python

"""ASGI streaming proxy. Preserve authentication, disconnects and SSE headers."""
from __future__ import annotations
import asyncio
import logging
import re
import time
from urllib.parse import quote
import httpx
from starlette.requests import Request
from starlette.responses import JSONResponse, StreamingResponse
from .controller import PoolUnavailable
THREAD_PATH = re.compile(r"^/api/threads/([^/]+)(?:/.*)?$")
START_PATH = re.compile(r"^/api/threads/[^/]+/runs(?:/(?:stream|wait))?$")
HOP_HEADERS = {b"host", b"connection", b"keep-alive", b"transfer-encoding", b"upgrade", b"proxy-authenticate", b"proxy-authorization", b"te", b"trailer", b"content-length", b"x-deerflow-worker-token"}
def _main_has_persistent_chat_storage(app) -> bool:
checkpointer = getattr(app.state, "checkpointer", None)
store = getattr(app.state, "store", None)
if checkpointer is None or store is None:
return False
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.store.memory import InMemoryStore
return not isinstance(checkpointer, InMemorySaver) and not isinstance(store, InMemoryStore)
async def send_upstream(client, method, url, headers, content):
# A keep-alive socket may close while a completed run is being queried.
# Retry only body-free reads, before downstream headers have been sent.
attempts = 2 if method in {"GET", "HEAD"} else 1
for attempt in range(attempts):
upstream = client.build_request(method, url, headers=headers, content=None if method in {"GET", "HEAD"} else content)
try:
return await client.send(upstream, stream=True)
except httpx.TransportError:
if attempt + 1 == attempts:
raise
await asyncio.sleep(0.05)
class WorkerProxy:
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
return await self.app(scope, receive, send)
pool = getattr(scope["app"].state, "worker_pool", None)
match = THREAD_PATH.fullmatch(scope["path"])
if pool is None or match is None or match[1] in {"search", "count", "reference-debug-log"}:
return await self.app(scope, receive, send)
if pool.db is None and not pool.store.load().enabled:
return await self.app(scope, receive, send)
row = pool._ensure_db().execute("SELECT node FROM affinity WHERE thread=?", (match[1],)).fetchone()
creating = scope["method"] == "POST" and bool(START_PATH.fullmatch(scope["path"]))
if row is None and (not creating or not pool.store.load().enabled):
return await self.app(scope, receive, send)
# Authenticate before allocating durable routing state. The worker
# still performs the existing owner/permission checks for every route.
request = Request(scope, receive)
from fastapi import HTTPException
from app.gateway.deps import get_current_user_from_request, get_thread_store
try:
access_started = time.perf_counter()
user = getattr(request.state, "user", None) or await get_current_user_from_request(request)
allowed = await get_thread_store(request).check_access(match[1], str(user.id), require_existing=True)
if not allowed:
return await JSONResponse({"detail": "Thread not found"}, 404)(scope, receive, send)
if row is None:
from deerflow.persistence.thread_meta.base import is_excluded_system_thread
record = await get_thread_store(request).get(match[1], user_id=None)
if record and is_excluded_system_thread(record.get("metadata")):
return await self.app(scope, receive, send)
manager = getattr(scope["app"].state, "run_manager", None)
if creating and pool.local_threads.get(match[1], 0):
return await JSONResponse({"detail": "会话仍在主服务执行,请完成当前轮后再提交"}, 409)(scope, receive, send)
if creating and manager is not None and any(
record.thread_id == match[1]
and getattr(record.status, "value", record.status) in {"pending", "running"}
and (record.task is None or not record.task.done())
for record in manager._runs.values()
):
return await JSONResponse({"detail": "会话仍在执行,请完成当前轮后再提交"}, 409)(scope, receive, send)
node = await pool.reserve(match[1], create=creating)
scope.setdefault("state", {})["worker_access_ms"] = round((time.perf_counter() - access_started) * 1000, 2)
except HTTPException as exc:
return await JSONResponse({"detail": exc.detail}, exc.status_code)(scope, receive, send)
except PoolUnavailable as exc:
if creating and pool.local_threads.get(match[1], 0):
return await JSONResponse({"detail": str(exc)}, 409)(scope, receive, send)
return await JSONResponse({"detail": str(exc)}, 503, headers={"Retry-After": "3"})(scope, receive, send)
if node is None:
return await self.app(scope, receive, send)
if node.get("_fallback_to_main"):
fallback_worker = node.get("_worker")
if creating and fallback_worker is not None:
try:
active_response = await pool.http.get(
fallback_worker["url"] + "/internal/worker-thread-active/" + quote(match[1], safe=""),
headers={"x-deerflow-worker-token": pool.store.raw()["token"]},
timeout=2,
)
active_response.raise_for_status()
if active_response.json().get("active"):
return await JSONResponse({"detail": "该会话正在 Worker 执行,请等待当前回答完成"}, 409)(scope, receive, send)
except httpx.HTTPError:
return await JSONResponse({"detail": "无法确认该会话是否正在 Worker 执行,请稍后重试"}, 503, headers={"Retry-After": "2"})(scope, receive, send)
if not _main_has_persistent_chat_storage(scope["app"]):
return await JSONResponse(
{"detail": "主服务的持久化聊天存储当前不可用,无法安全接管 Worker 请求"},
503,
headers={"Retry-After": "3"},
)(scope, receive, send)
scope.setdefault("state", {})["worker_pool_fallback"] = True
scope["state"]["worker_pool_fallback_reason"] = node.get("_fallback_reason")
return await self.app(scope, receive, send)
response = None
scope.setdefault("state", {})["worker_node"] = node["name"]
try:
headers = [(k, v) for k, v in scope["headers"] if k.lower() not in HOP_HEADERS or k.lower() == b"host"]
headers.append((b"x-deerflow-worker-token", pool.store.raw()["token"].encode()))
url = node["url"] + scope["path"]
if scope.get("query_string"):
url += "?" + scope["query_string"].decode("ascii")
upstream_started = time.perf_counter()
response = await send_upstream(pool.http, scope["method"], url, headers, request.stream())
scope["state"]["worker_upstream_headers_ms"] = round((time.perf_counter() - upstream_started) * 1000, 2)
async def chunks():
async for chunk in response.aiter_raw():
yield chunk
downstream = StreamingResponse(chunks(), status_code=response.status_code)
downstream.raw_headers = [(k, v) for k, v in response.headers.raw if k.lower() not in HOP_HEADERS]
await downstream(scope, receive, send)
except httpx.HTTPError as exc:
logging.getLogger(__name__).warning("Worker transport failed method=%s node=%s error=%s", scope["method"], node["name"], type(exc).__name__)
if response is None:
await JSONResponse({"detail": "执行节点连接失败。任务可能已开始,请查看会话状态后再决定是否重试。"}, 503)(scope, receive, send)
# Once SSE headers are sent, close the stream so the SDK reconnects.
finally:
if response is not None:
await response.aclose()
pool.release(node["name"])