- git mv 保留历史:ui/(Vue3+Vite)、sdk/(BFF 中间层)
- 路径语义修正:db_path/figures_cache 改相对 sdk/(BFF cwd),
历史库 sdk/backend/data 迁移至 sdk/data/(会话/图片缓存无损)
- 引用修复 ~15 处:config.py、deploy/*.sh、vitest/playwright 配置、
单测导入路径、docs、README、gitignore、.env.example
- 骨架:agent/{qa,methodology,legacy-rag,vendor}、knowledge/manifest.yaml、
tooling/{compile,kb,crypto,assemble,audit,tools,scripts} + README
- 门禁验证:sdk pytest 125 通过、ui vitest 158 通过、
生产 BFF 脚本重启四上游全绿 + 静态托管 + 会话库无损
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
602 lines
21 KiB
Python
602 lines
21 KiB
Python
"""test_sse_relay.py —— sse_relay 单元测试(A2 归属)。
|
||
|
||
全部测试对注入的假数据/假 client 运行,不依赖真实上游。看门狗测试用缩短的
|
||
超时参数注入(idle_timeout / keepalive_interval),避免真实 240s。
|
||
环境无 respx/pytest-asyncio:异步测试用 anyio 插件(pytest.mark.anyio)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
|
||
import pytest
|
||
|
||
from app.services import sse_relay
|
||
from app.services.sse_relay import (
|
||
STAGE_LABEL,
|
||
STAGE_PHASE,
|
||
UNKNOWN_STAGE_PHASE,
|
||
_classify,
|
||
phase_for,
|
||
relay_stream,
|
||
)
|
||
|
||
|
||
def _frame_texts(frames: list[str]) -> list[str]:
|
||
return [f for f in frames if f.strip()]
|
||
|
||
|
||
def _event_data_map(frames: list[str]) -> dict[str, list[dict]]:
|
||
"""把 SSE 帧序列解析为 {event_name: [data_dict, ...]}。"""
|
||
out: dict[str, list[dict]] = {}
|
||
event = None
|
||
for frame in frames:
|
||
for line in frame.split("\n"):
|
||
if line.startswith("event:"):
|
||
event = line[len("event:"):].strip()
|
||
out.setdefault(event, [])
|
||
elif line.startswith("data:") and event:
|
||
out[event].append(json.loads(line[len("data:"):].strip()))
|
||
return out
|
||
|
||
|
||
def _sse_bytes(events: list[tuple[bytes, bytes]]) -> list[bytes]:
|
||
return [b"event: " + e + b"\ndata: " + d + b"\n\n" for e, d in events]
|
||
|
||
|
||
# ===========================================================================
|
||
# STAGE_PHASE / STAGE_LABEL 全覆盖
|
||
# ===========================================================================
|
||
|
||
|
||
def test_stage_phase_maps_all_11_known_stages():
|
||
expected = {
|
||
"understanding_started": ("planning", "开始理解问题"),
|
||
"entities_identified": ("planning", "已识别实体"),
|
||
"related_search_started": ("planning", "开始关联检索"),
|
||
"concepts_found": ("retrieval", "发现相关概念"),
|
||
"subquestion_retrieved": ("retrieval", "子问题召回完成"),
|
||
"agent_review_started": ("retrieval", "开始智能体审核"),
|
||
"subquestion_retry_started": ("retrieval", "子问题重试中"),
|
||
"evidence_accepted": ("evidence", "证据已采纳"),
|
||
"evidence_review_completed": ("evidence", "证据审核完成"),
|
||
"answer_generation_started": ("generation", "开始生成答案"),
|
||
"completed": ("done", "检索完成"),
|
||
}
|
||
assert len(STAGE_PHASE) == 11
|
||
assert len(STAGE_LABEL) == 11
|
||
for stage, (phase, label) in expected.items():
|
||
assert STAGE_PHASE[stage] == phase
|
||
assert STAGE_LABEL[stage] == label
|
||
assert phase_for("not_a_real_stage") == (UNKNOWN_STAGE_PHASE, "not_a_real_stage")
|
||
|
||
|
||
def test_unknown_stage_falls_back_to_retrieval():
|
||
assert STAGE_PHASE.get("future_stage_xyz") is None
|
||
phase, label = phase_for("future_stage_xyz")
|
||
assert phase == "retrieval"
|
||
assert label == "future_stage_xyz"
|
||
|
||
|
||
# ===========================================================================
|
||
# 帧分类:无 event 名按形态、多行 data、: 注释
|
||
# ===========================================================================
|
||
|
||
|
||
def test_classify_stage_payload():
|
||
kind, payload = _classify("stage", '{"stage":"entities_identified","details":{"entities":["OsSPL14"]}}')
|
||
assert kind == "progress"
|
||
assert payload["stage"] == "entities_identified"
|
||
|
||
|
||
def test_classify_terminal_complete():
|
||
kind, payload = _classify("complete", '{"answer":"xxx","success":true,"sources":[]}')
|
||
assert kind == "complete"
|
||
|
||
|
||
def test_classify_no_event_name_by_shape():
|
||
# 无 event 名:按 payload 形态分类
|
||
assert _classify(None, '{"stage":"concepts_found","details":{}}')[0] == "progress"
|
||
assert _classify(None, '{"answer":"...","sources":[]}')[0] == "complete"
|
||
assert _classify(None, '{"success":false,"detail":{"error_code":"x"}}')[0] == "complete" # 含 success 判终态
|
||
assert _classify(None, '{"foo":1}')[0] == "raw"
|
||
|
||
|
||
def test_classify_error_event():
|
||
kind, payload = _classify("error", '{"detail":{"error_code":"invalid_llm_citation"}}')
|
||
assert kind == "error"
|
||
assert payload["code"] == "INVALID_CITATION"
|
||
|
||
|
||
def test_classify_raw_never_drops():
|
||
# 非 JSON data
|
||
kind, payload = _classify(None, "not-json-at-all")
|
||
assert kind == "raw"
|
||
assert payload["payload"] == "not-json-at-all"
|
||
# 无法分类的 JSON
|
||
kind, payload = _classify("mystery", '{"unexpected":true}')
|
||
assert kind == "raw"
|
||
assert payload == {"unexpected": True}
|
||
# JSON 数组
|
||
kind, payload = _classify(None, "[1,2,3]")
|
||
assert kind == "raw"
|
||
assert payload == [1, 2, 3]
|
||
# 空 data 但有 event 名
|
||
kind, payload = _classify("some_event", "")
|
||
assert kind == "raw"
|
||
assert payload["event_name"] == "some_event"
|
||
|
||
|
||
# ===========================================================================
|
||
# 中继整体:mock client 注入
|
||
# ===========================================================================
|
||
|
||
|
||
class FakeUpstreamStream:
|
||
"""httpx stream 上下文的假实现:按给定 chunks 序列吐字节。
|
||
|
||
silent=True 时 aiter_raw 永久挂起(模拟上游连接保持但完全静默)。
|
||
"""
|
||
|
||
def __init__(self, chunks: list[bytes], status: int = 200, headers: dict | None = None,
|
||
json_body: dict | None = None, silent: bool = False):
|
||
self._chunks = list(chunks)
|
||
self.status_code = status
|
||
self.headers = headers or {}
|
||
self._json = json_body
|
||
self._silent = silent
|
||
|
||
async def aiter_raw(self):
|
||
if self._silent:
|
||
import anyio
|
||
|
||
await anyio.sleep_forever()
|
||
return # pragma: no cover
|
||
for c in self._chunks:
|
||
yield c
|
||
|
||
async def aread(self):
|
||
return json.dumps(self._json).encode() if self._json is not None else b""
|
||
|
||
|
||
class _StreamCtx:
|
||
def __init__(self, response: FakeUpstreamStream):
|
||
self._resp = response
|
||
|
||
async def __aenter__(self):
|
||
return self._resp
|
||
|
||
async def __aexit__(self, exc_type, exc, tb):
|
||
return False
|
||
|
||
|
||
class FakeClient:
|
||
"""按顺序弹出响应的假 httpx.AsyncClient;记录请求供断言。"""
|
||
|
||
def __init__(self, responses: list[FakeUpstreamStream], exc: Exception | None = None):
|
||
self._responses = list(responses)
|
||
self._exc = exc
|
||
self.calls: list[dict] = []
|
||
|
||
def stream(self, method, url, **kwargs):
|
||
self.calls.append({"method": method, "url": url, **kwargs})
|
||
if self._exc is not None:
|
||
exc, self._exc = self._exc, None
|
||
raise exc
|
||
resp = self._responses.pop(0)
|
||
return _StreamCtx(resp)
|
||
|
||
|
||
def _collect(agen) -> list[str]:
|
||
import asyncio
|
||
|
||
async def run():
|
||
return [f async for f in agen]
|
||
|
||
return asyncio.run(run())
|
||
|
||
|
||
def test_relay_emits_progress_complete_sequence():
|
||
import anyio
|
||
|
||
frames = [
|
||
("stage", '{"stage":"understanding_started","details":{}}'),
|
||
("stage", '{"stage":"entities_identified","details":{"entities":["OsSPL14"]}}'),
|
||
("stage", '{"stage":"completed","details":{"elapsed_seconds":38.3}}'),
|
||
("complete", '{"answer":"final answer","success":true,"sources":[{"title":"T","score":0.9}]}'),
|
||
]
|
||
client = FakeClient([FakeUpstreamStream(_sse_bytes([(e.encode(), d.encode()) for e, d in frames]))])
|
||
|
||
async def run():
|
||
out = [f async for f in relay_stream({"question": "q"}, client=client, base_url="http://up")]
|
||
return out
|
||
|
||
out = anyio.run(run)
|
||
events = _event_data_map(_frame_texts(out))
|
||
assert len(events["progress"]) == 3
|
||
assert events["progress"][0]["stage"] == "understanding_started"
|
||
assert events["progress"][0]["phase"] == "planning"
|
||
assert events["progress"][1]["phase"] == "planning"
|
||
assert events["progress"][2]["phase"] == "done"
|
||
assert events["complete"][0]["mode"] == "chat"
|
||
assert "at" in events["progress"][0]
|
||
assert client.calls[0]["url"] == "http://up/api/v1/qa-v1-1-73/chat/stream"
|
||
assert client.calls[0]["json"] == {"question": "q"}
|
||
|
||
|
||
def test_relay_multiline_data_and_comment_frames():
|
||
import anyio
|
||
|
||
comment = b": keepalive\n\n"
|
||
chunks = [
|
||
b"event: stage\ndata: {\"stage\":\"evidence_accepted\",\n",
|
||
b"data: \"details\":{\"accepted_count\":12}}\n\n",
|
||
comment,
|
||
b"event: complete\ndata: {\"answer\":\"A\"}\n\n",
|
||
]
|
||
client = FakeClient([FakeUpstreamStream(chunks)])
|
||
|
||
async def run():
|
||
return [f async for f in relay_stream({"question": "q"}, client=client, base_url="http://up")]
|
||
|
||
out = anyio.run(run)
|
||
events = _event_data_map(_frame_texts(out))
|
||
assert "progress" in events
|
||
assert events["progress"][0]["details"] == {"accepted_count": 12}
|
||
assert "complete" in events
|
||
assert all(not f.startswith(": keepalive") for f in _frame_texts(out))
|
||
|
||
|
||
# ===========================================================================
|
||
# evidence_first(2026-08-26 方案 3):并发 search 注入非终态 evidence 帧
|
||
# ===========================================================================
|
||
|
||
|
||
def test_evidence_first_off_no_search_call():
|
||
import anyio
|
||
|
||
frames = [("complete", '{"answer":"A","success":true,"sources":[]}')]
|
||
# 只放一个响应:若 evidence_first=False 却发了 search,会 pop 掉 chat 的响应导致挂死
|
||
client = FakeClient([FakeUpstreamStream(_sse_bytes([(e.encode(), d.encode()) for e, d in frames]))])
|
||
|
||
async def run():
|
||
return [f async for f in relay_stream({"question": "q"}, client=client, base_url="http://up")]
|
||
|
||
out = anyio.run(run)
|
||
events = _event_data_map(_frame_texts(out))
|
||
assert "evidence" not in events
|
||
assert len(client.calls) == 1 # 只有 chat/stream 一次调用
|
||
assert client.calls[0]["url"].endswith("/chat/stream")
|
||
|
||
|
||
def test_evidence_first_emits_evidence_before_complete():
|
||
import anyio
|
||
|
||
chat_frames = [
|
||
("stage", '{"stage":"concepts_found","details":{}}'),
|
||
("complete", '{"answer":"final","success":true,"sources":[{"title":"chat-src"}]}'),
|
||
]
|
||
search_body = {"success": True, "answer": "", "sources": [{"title": "search-src"}],
|
||
"native_graph_chains": [], "timings": {"total": 92.0}}
|
||
chat = FakeUpstreamStream(_sse_bytes([(e.encode(), d.encode()) for e, d in chat_frames]))
|
||
search = FakeUpstreamStream([], status=200, json_body=search_body)
|
||
client = FakeClient([chat, search]) # 顺序确定:主链先弹 chat,evidence task 弹 search
|
||
|
||
async def run():
|
||
return [f async for f in relay_stream(
|
||
{"question": "q", "use_llm": True}, client=client, base_url="http://up",
|
||
evidence_first=True)]
|
||
|
||
out = anyio.run(run)
|
||
texts = _frame_texts(out)
|
||
events = _event_data_map(texts)
|
||
# evidence 帧存在且 answer 为空、mode 为 search
|
||
assert "evidence" in events
|
||
assert events["evidence"][0]["answer"] == ""
|
||
assert events["evidence"][0]["mode"] == "search"
|
||
assert events["evidence"][0]["sources"][0]["title"] == "search-src"
|
||
# 顺序:evidence 在 complete 之前
|
||
ev_idx = texts.index(next(f for f in texts if f.startswith("event: evidence")))
|
||
cp_idx = texts.index(next(f for f in texts if f.startswith("event: complete")))
|
||
assert ev_idx < cp_idx
|
||
# 两次上游调用:chat/stream 与 search(use_llm 强制 false、图链关闭防抢 Neo4j)
|
||
assert len(client.calls) == 2
|
||
assert client.calls[1]["url"].endswith("/search")
|
||
assert client.calls[1]["json"]["use_llm"] is False
|
||
assert client.calls[1]["json"]["native_chain_enabled"] is False
|
||
assert client.calls[1]["json"]["question"] == "q"
|
||
|
||
|
||
def test_evidence_first_search_failure_degrades_to_plain_chat():
|
||
import anyio
|
||
|
||
chat_frames = [("complete", '{"answer":"final","success":true,"sources":[]}')]
|
||
chat = FakeUpstreamStream(_sse_bytes([(e.encode(), d.encode()) for e, d in chat_frames]))
|
||
search_fail = FakeUpstreamStream([], status=500, json_body={"detail": "boom"})
|
||
client = FakeClient([chat, search_fail])
|
||
|
||
async def run():
|
||
return [f async for f in relay_stream(
|
||
{"question": "q"}, client=client, base_url="http://up", evidence_first=True)]
|
||
|
||
out = anyio.run(run)
|
||
events = _event_data_map(_frame_texts(out))
|
||
assert "evidence" not in events # 静默降级
|
||
assert "complete" in events # 主链不受影响
|
||
|
||
|
||
def test_evidence_first_complete_first_no_evidence_injected():
|
||
import anyio
|
||
|
||
# chat 先终态;search 虽成功但已无机会注入(complete 已发)
|
||
chat_frames = [("complete", '{"answer":"fast","success":true,"sources":[]}')]
|
||
chat = FakeUpstreamStream(_sse_bytes([(e.encode(), d.encode()) for e, d in chat_frames]))
|
||
search_body = {"success": True, "answer": "", "sources": [{"title": "late"}]}
|
||
search = FakeUpstreamStream([], status=200, json_body=search_body)
|
||
client = FakeClient([chat, search])
|
||
|
||
async def run():
|
||
return [f async for f in relay_stream(
|
||
{"question": "q"}, client=client, base_url="http://up", evidence_first=True)]
|
||
|
||
out = anyio.run(run)
|
||
events = _event_data_map(_frame_texts(out))
|
||
assert "complete" in events
|
||
# evidence 要么不存在,要么在 complete 之后(前端 ignore);BFF 保证不插在终态后
|
||
texts = _frame_texts(out)
|
||
ev = [f for f in texts if f.startswith("event: evidence")]
|
||
cp_idx = texts.index(next(f for f in texts if f.startswith("event: complete")))
|
||
for e in ev:
|
||
assert texts.index(e) < cp_idx
|
||
|
||
|
||
def test_relay_no_event_name_classified_by_shape():
|
||
import anyio
|
||
|
||
# 上游不带 event: 行,纯 data: JSON(模拟变体)
|
||
chunks = [
|
||
b'data: {"stage":"concepts_found","details":{}}\n\n',
|
||
b'data: {"answer":"A","success":true}\n\n',
|
||
]
|
||
client = FakeClient([FakeUpstreamStream(chunks)])
|
||
|
||
async def run():
|
||
return [f async for f in relay_stream({"q": 1}, client=client, base_url="http://up")]
|
||
|
||
out = anyio.run(run)
|
||
events = _event_data_map(_frame_texts(out))
|
||
assert events["progress"][0]["phase"] == "retrieval"
|
||
assert events["complete"][0]["answer"] == "A"
|
||
|
||
|
||
def test_relay_unknown_json_goes_raw():
|
||
import anyio
|
||
|
||
chunks = [
|
||
b"data: {\"mystery\":\"payload\"}\n\n",
|
||
b"event: complete\ndata: {\"answer\":\"A\"}\n\n",
|
||
]
|
||
client = FakeClient([FakeUpstreamStream(chunks)])
|
||
|
||
async def run():
|
||
return [f async for f in relay_stream({}, client=client, base_url="http://up")]
|
||
|
||
out = anyio.run(run)
|
||
events = _event_data_map(_frame_texts(out))
|
||
assert events["raw"] == [{"mystery": "payload"}]
|
||
assert "complete" in events
|
||
|
||
|
||
def test_relay_unknown_stage_maps_to_retrieval_with_raw_label():
|
||
import anyio
|
||
|
||
chunks = [b'data: {"stage":"brand_new_stage","details":{"x":1}}\n\n']
|
||
client = FakeClient([FakeUpstreamStream(chunks)])
|
||
|
||
async def run():
|
||
return [f async for f in relay_stream({}, client=client, base_url="http://up")]
|
||
|
||
out = anyio.run(run)
|
||
events = _event_data_map(_frame_texts(out))
|
||
assert events["progress"][0]["phase"] == "retrieval"
|
||
assert events["progress"][0]["label"] == "brand_new_stage"
|
||
|
||
|
||
def test_relay_error_event_closes():
|
||
import anyio
|
||
|
||
chunks = [
|
||
b'event: error\ndata: {"detail":{"error_code":"invalid_llm_citation"}}\n\n',
|
||
b"data: {\"stage\":\"completed\"}\n\n", # 关闭后不应被处理
|
||
]
|
||
client = FakeClient([FakeUpstreamStream(chunks)])
|
||
|
||
async def run():
|
||
return [f async for f in relay_stream({}, client=client, base_url="http://up")]
|
||
|
||
out = anyio.run(run)
|
||
events = _event_data_map(_frame_texts(out))
|
||
assert list(events.keys()) == ["error"]
|
||
assert events["error"][0]["code"] == "INVALID_CITATION"
|
||
|
||
|
||
def test_relay_upstream_close_before_terminal_reports_error():
|
||
import anyio
|
||
|
||
chunks = [b'data: {"stage":"concepts_found","details":{}}\n\n'] # 无 complete 即 EOF
|
||
client = FakeClient([FakeUpstreamStream(chunks)])
|
||
|
||
async def run():
|
||
return [f async for f in relay_stream({}, client=client, base_url="http://up")]
|
||
|
||
out = anyio.run(run)
|
||
events = _event_data_map(_frame_texts(out))
|
||
# 残余帧先转发(绝不丢数据),再报错误
|
||
assert "progress" in events
|
||
assert events["error"][0]["code"] == "UPSTREAM_ERROR"
|
||
|
||
|
||
# ===========================================================================
|
||
# 看门狗(缩短超时注入)
|
||
# ===========================================================================
|
||
|
||
|
||
def test_watchdog_timeout_after_idle():
|
||
import anyio
|
||
|
||
# 上游连接保持但完全静默;idle_timeout=0.3s 注入
|
||
client = FakeClient([FakeUpstreamStream([], silent=True)])
|
||
|
||
async def run():
|
||
return [
|
||
f
|
||
async for f in relay_stream(
|
||
{}, client=client, base_url="http://up", idle_timeout=0.3, keepalive_interval=0.15
|
||
)
|
||
]
|
||
|
||
out = anyio.run(run)
|
||
events = _event_data_map(_frame_texts(out))
|
||
assert list(events.keys()) == ["timeout"]
|
||
assert events["timeout"][0]["message"] == "检索超时"
|
||
|
||
|
||
def test_watchdog_ping_before_timeout():
|
||
import anyio
|
||
|
||
client = FakeClient([FakeUpstreamStream([], silent=True)])
|
||
|
||
async def run():
|
||
return [
|
||
f
|
||
async for f in relay_stream(
|
||
{}, client=client, base_url="http://up", idle_timeout=0.6, keepalive_interval=0.2
|
||
)
|
||
]
|
||
|
||
out = anyio.run(run)
|
||
pings = [f for f in _frame_texts(out) if f.startswith(": ping")]
|
||
assert len(pings) >= 1
|
||
events = _event_data_map(_frame_texts(out))
|
||
assert "timeout" in events
|
||
|
||
|
||
def test_watchdog_does_not_fire_when_events_keep_coming():
|
||
import anyio
|
||
|
||
# 上游在 idle_timeout 窗口内持续给事件(逐块喂,模拟慢速流)
|
||
chunks = [
|
||
b'data: {"stage":"understanding_started"}\n\n',
|
||
b'data: {"stage":"entities_identified"}\n\n',
|
||
b'event: complete\ndata: {"answer":"A"}\n\n',
|
||
]
|
||
client = FakeClient([FakeUpstreamStream(chunks)])
|
||
|
||
async def run():
|
||
return [
|
||
f
|
||
async for f in relay_stream(
|
||
{}, client=client, base_url="http://up", idle_timeout=10.0, keepalive_interval=0.05
|
||
)
|
||
]
|
||
|
||
out = anyio.run(run)
|
||
events = _event_data_map(_frame_texts(out))
|
||
assert "complete" in events
|
||
assert "timeout" not in events
|
||
|
||
|
||
# ===========================================================================
|
||
# 上游 HTTP 头阶段失败:invalid_llm_citation 重试一次 → 仍失败 → error
|
||
# ===========================================================================
|
||
|
||
|
||
def _http_error_response(body: dict, status: int) -> FakeUpstreamStream:
|
||
return FakeUpstreamStream([], status=status, json_body=body)
|
||
|
||
|
||
def test_http_error_402_invalid_citation_retries_then_errors():
|
||
import anyio
|
||
|
||
client = FakeClient([
|
||
_http_error_response({"detail": {"error_code": "invalid_llm_citation"}}, 402),
|
||
_http_error_response({"detail": {"error_code": "invalid_llm_citation"}}, 402),
|
||
])
|
||
|
||
async def run():
|
||
return [f async for f in relay_stream({}, client=client, base_url="http://up")]
|
||
|
||
out = anyio.run(run)
|
||
events = _event_data_map(_frame_texts(out))
|
||
assert list(events.keys()) == ["error"]
|
||
assert events["error"][0]["code"] == "INVALID_CITATION"
|
||
assert events["error"][0]["retryable"] is True
|
||
assert len(client.calls) == 2 # 重试一次 = 总共 2 次请求
|
||
|
||
|
||
def test_http_error_invalid_citation_retries_then_succeeds():
|
||
import anyio
|
||
|
||
good = FakeUpstreamStream(
|
||
[b'event: complete\ndata: {"answer":"ok"}\n\n'],
|
||
status=200,
|
||
)
|
||
client = FakeClient([
|
||
_http_error_response({"detail": {"error_code": "invalid_llm_citation"}}, 402),
|
||
good,
|
||
])
|
||
|
||
async def run():
|
||
return [f async for f in relay_stream({}, client=client, base_url="http://up")]
|
||
|
||
out = anyio.run(run)
|
||
events = _event_data_map(_frame_texts(out))
|
||
assert "complete" in events
|
||
assert "error" not in events
|
||
assert len(client.calls) == 2
|
||
|
||
|
||
def test_http_error_other_status_no_retry():
|
||
import anyio
|
||
|
||
client = FakeClient([
|
||
_http_error_response({"detail": "boom"}, 500),
|
||
])
|
||
|
||
async def run():
|
||
return [f async for f in relay_stream({}, client=client, base_url="http://up")]
|
||
|
||
out = anyio.run(run)
|
||
events = _event_data_map(_frame_texts(out))
|
||
assert events["error"][0]["code"] == "UPSTREAM_ERROR"
|
||
assert len(client.calls) == 1
|
||
|
||
|
||
def test_connect_error_emits_upstream_down():
|
||
import anyio
|
||
import httpx
|
||
|
||
client = FakeClient([], exc=httpx.ConnectError("conn refused", request=None))
|
||
|
||
async def run():
|
||
return [f async for f in relay_stream({}, client=client, base_url="http://up")]
|
||
|
||
out = anyio.run(run)
|
||
events = _event_data_map(_frame_texts(out))
|
||
assert events["error"][0]["code"] == "UPSTREAM_DOWN"
|
||
|
||
|
||
# ===========================================================================
|
||
# 工具函数
|
||
# ===========================================================================
|
||
|
||
|
||
def test_sse_headers_contract():
|
||
assert sse_relay.sse_headers["Cache-Control"] == "no-cache"
|
||
assert sse_relay.sse_headers["X-Accel-Buffering"] == "no"
|
||
|
||
|
||
def test_error_payload_shapes():
|
||
assert sse_relay.error_payload("TIMEOUT") == {"code": "TIMEOUT", "message": "检索超时", "retryable": True}
|
||
assert sse_relay.error_payload("UPSTREAM_DOWN")["retryable"] is False
|
||
assert sse_relay.error_payload("INVALID_CITATION")["retryable"] is True
|